@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
package/cli/new.mjs
CHANGED
|
@@ -1,43 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// `new
|
|
3
|
-
// into the user's OWN layer, never into `core/`.
|
|
2
|
+
// `portulan new`: scaffold a skill, persona, pack, workspace, gate policy or repo card, never into `core/`.
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// ## The one property this tool states in the imperative
|
|
8
|
-
//
|
|
9
|
-
// **Never into `core/`.** Row 7 says it, and it is the reason this tool exists at all rather than being
|
|
10
|
-
// advice: the cascade runs core < pack < workspace < repo card < task, and a team that answers "where do
|
|
11
|
-
// I put my own skill?" by editing `core/` has merged their specifics into the layer that ships to
|
|
12
|
-
// everybody. Thesis 6 of the constitution — storage follows ownership.
|
|
13
|
-
//
|
|
14
|
-
// The refusal is **resolution-based, not pattern-based**, because every interesting escape parses fine:
|
|
15
|
-
// `packs/../core` matches any path rule you could write, and a symlinked destination matches nothing at
|
|
16
|
-
// all until you follow it. So the check resolves first and compares after — the same conclusion
|
|
17
|
-
// `spec/pack.schema.json`'s `$defs/filePath` reaches from the other side, and the one `cli/compile.mjs`
|
|
18
|
-
// already acts on for its policy path.
|
|
19
|
-
//
|
|
20
|
-
// ## Two exit codes, deliberately
|
|
21
|
-
//
|
|
22
|
-
// **0 wrote · 2 could not run.** There is no exit 1, for the same reason `init` has none: this tool
|
|
23
|
-
// renders no verdict about anybody's workspace, so it has no red to report. `doctor` is what says
|
|
24
|
-
// whether what landed is any good, and conflating "I could not write this" with "what I wrote is wrong"
|
|
25
|
-
// would make a caller unable to tell a missing template from a bad scaffold.
|
|
26
|
-
//
|
|
27
|
-
// ## What it refuses, ahead of the first byte
|
|
28
|
-
//
|
|
29
|
-
// A file it would overwrite — **any** file, never only a manifest. Session 1 shipped a residence check
|
|
30
|
-
// keyed on `workspace.json` and treated it as an answer to *is it safe to write here?*; a hand-written
|
|
31
|
-
// `gate-map.md` was silently overwritten. Two questions, one key: that was the whole defect. Here there
|
|
32
|
-
// is only one question and it is asked about every path.
|
|
33
|
-
//
|
|
34
|
-
// A symlink anywhere on the destination chain — resolved links are how a write leaves the tree it was
|
|
35
|
-
// meant to stay inside, measured on `init` at nine files written into an unrelated directory and
|
|
36
|
-
// reported as success.
|
|
37
|
-
//
|
|
38
|
-
// And an `lstat` failure that is **not** `ENOENT`, because only `ENOENT` means absent. `EACCES` means
|
|
39
|
-
// the question could not be answered, and answering *nothing there* to an unanswerable question is
|
|
40
|
-
// "nothing looked" recorded as "nothing wrong".
|
|
4
|
+
// Exit 0 wrote · 2 could not run; never 1, since it renders no verdict on a workspace.
|
|
41
5
|
|
|
42
6
|
import fs from "node:fs";
|
|
43
7
|
import path from "node:path";
|
|
@@ -46,36 +10,20 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
46
10
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
47
11
|
const CORE = path.resolve(HERE, "..", "core");
|
|
48
12
|
|
|
49
|
-
/** Raised for anything that means `new` could not run. Carries no verdict about a workspace. */
|
|
50
13
|
export class NewError extends Error {}
|
|
51
14
|
|
|
52
|
-
/**
|
|
53
|
-
* The six kinds row 7 names. A Set rather than an array so membership is the question asked, and
|
|
54
|
-
* exported so the suite compares against the row rather than against a count that passes while a name
|
|
55
|
-
* is wrong.
|
|
56
|
-
*/
|
|
57
15
|
export const KINDS = new Set(["skill", "persona", "pack", "workspace", "gate-policy", "repo-card"]);
|
|
58
16
|
|
|
59
|
-
/**
|
|
17
|
+
/** `$defs/slug`, as both schemas hold it. */
|
|
60
18
|
export const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
61
19
|
|
|
62
|
-
/** Kinds whose artifact is JSON, extracted from the template's first fenced `json` block. */
|
|
63
20
|
const JSON_KINDS = new Set(["pack", "workspace", "gate-policy"]);
|
|
64
21
|
|
|
65
|
-
/** Where a kind's core template lives. One place, so a missing template is one failure and not six. */
|
|
66
22
|
export function template(kind) {
|
|
67
23
|
return path.join(CORE, "templates", `${kind}.md`);
|
|
68
24
|
}
|
|
69
25
|
|
|
70
|
-
/**
|
|
71
|
-
* The skeleton inside a template — everything after the first standalone `---`, or, for the JSON kinds,
|
|
72
|
-
* the first fenced ```json block after it.
|
|
73
|
-
*
|
|
74
|
-
* The separator is the first `---` at column 0 rather than the last, which matters for the skill
|
|
75
|
-
* template specifically: its skeleton BEGINS with YAML frontmatter, so the file legitimately contains
|
|
76
|
-
* two `---` lines in a row and taking the last one would swallow the frontmatter the skeleton exists to
|
|
77
|
-
* provide.
|
|
78
|
-
*/
|
|
26
|
+
/** After the first `---`, not the last: the skill template's skeleton opens with frontmatter of its own. */
|
|
79
27
|
export function skeleton(kind, source) {
|
|
80
28
|
const lines = source.split("\n");
|
|
81
29
|
const at = lines.findIndex((line) => line.trimEnd() === "---");
|
|
@@ -91,14 +39,6 @@ export function skeleton(kind, source) {
|
|
|
91
39
|
return `${body.slice(start, close).trim()}\n`;
|
|
92
40
|
}
|
|
93
41
|
|
|
94
|
-
/**
|
|
95
|
-
* Where a scaffold lands, as a pure function of kind, name and target.
|
|
96
|
-
*
|
|
97
|
-
* A repo card resolves through the workspace's **declared** `repos` slot rather than through a
|
|
98
|
-
* convention, because deriving a path a manifest does not declare is the tool inventing a slot — and a
|
|
99
|
-
* workspace that disagrees with its own manifest on the day it was scaffolded is exactly the drift the
|
|
100
|
-
* claims lint exists to catch.
|
|
101
|
-
*/
|
|
102
42
|
export function destination(kind, name, target) {
|
|
103
43
|
switch (kind) {
|
|
104
44
|
case "skill":
|
|
@@ -109,12 +49,6 @@ export function destination(kind, name, target) {
|
|
|
109
49
|
return path.join(target, `${name}.json`);
|
|
110
50
|
case "repo-card": {
|
|
111
51
|
const slot = declaredSlot(target, "repos");
|
|
112
|
-
// The slot value is the WORKSPACE's, not this tool's, and a `..` in it escapes:
|
|
113
|
-
// `path.join("/ws/sub", "../../evil")` is `/evil`, measured. (An *absolute* slot does not
|
|
114
|
-
// escape `join` — that is `resolve`'s behaviour, not `join`'s — so only the `..` half of the
|
|
115
|
-
// review that found this was right, and the fix is written against what was measured rather
|
|
116
|
-
// than against what was reported.) Checked after resolution, like every other containment
|
|
117
|
-
// question here. Copilot, round 3 on #156.
|
|
118
52
|
const landing = path.resolve(target, slot, `${name}.md`);
|
|
119
53
|
const root = path.resolve(target);
|
|
120
54
|
if (landing !== root && !landing.startsWith(`${root}${path.sep}`)) {
|
|
@@ -131,7 +65,6 @@ export function destination(kind, name, target) {
|
|
|
131
65
|
}
|
|
132
66
|
}
|
|
133
67
|
|
|
134
|
-
/** Reads one slot out of a workspace manifest, refusing rather than guessing when it is not declared. */
|
|
135
68
|
function declaredSlot(workspaceDir, slot) {
|
|
136
69
|
const manifestPath = path.join(workspaceDir, "workspace.json");
|
|
137
70
|
let manifest;
|
|
@@ -157,18 +90,6 @@ function declaredSlot(workspaceDir, slot) {
|
|
|
157
90
|
return declared;
|
|
158
91
|
}
|
|
159
92
|
|
|
160
|
-
/**
|
|
161
|
-
* Which of the destination paths already exist, and which sit behind a symlink.
|
|
162
|
-
*
|
|
163
|
-
* `lstatSync`, never `existsSync`/`statSync` — both of those follow links, which is how `init` wrote
|
|
164
|
-
* nine files outside a repository and reported success. A symlink is reported as its own state rather
|
|
165
|
-
* than resolved and permitted: deciding whether a link's target is "really" inside the intended tree is
|
|
166
|
-
* a containment judgement with a bad failure mode, where refusing has none.
|
|
167
|
-
*
|
|
168
|
-
* An existing **directory** is not a collision. This tool writes files into directories a user may
|
|
169
|
-
* legitimately have created; refusing an empty directory would make `new` unusable in the ordinary case
|
|
170
|
-
* and teach nobody anything.
|
|
171
|
-
*/
|
|
172
93
|
export function collisions(paths, root = null) {
|
|
173
94
|
const found = [];
|
|
174
95
|
for (const target of paths) {
|
|
@@ -177,8 +98,6 @@ export function collisions(paths, root = null) {
|
|
|
177
98
|
try {
|
|
178
99
|
stat = fs.lstatSync(step);
|
|
179
100
|
} catch (cause) {
|
|
180
|
-
// Only ENOENT means absent. Anything else means the question could not be answered, and
|
|
181
|
-
// an unanswerable question must not be answered "no".
|
|
182
101
|
if (cause.code === "ENOENT") continue;
|
|
183
102
|
found.push({ path: step, why: "unreadable", detail: cause.code ?? cause.message });
|
|
184
103
|
break;
|
|
@@ -187,12 +106,6 @@ export function collisions(paths, root = null) {
|
|
|
187
106
|
found.push({ path: step, why: "symlink" });
|
|
188
107
|
break;
|
|
189
108
|
}
|
|
190
|
-
// A DIRECTORY at the leaf is a collision too, and the paragraph above says the opposite —
|
|
191
|
-
// rightly, about the *intermediate* segments, and wrongly here: every path this plans is a
|
|
192
|
-
// file, so a directory sitting at one is something already there. Preflight passed and
|
|
193
|
-
// `writeFileSync` then threw `EISDIR` mid-loop, which is the ordering this whole function
|
|
194
|
-
// exists to prevent. Found by Copilot on #164 against the copy of this shape in
|
|
195
|
-
// `cli/vendor.mjs`; fixed in both, because a defect and its source are the same defect.
|
|
196
109
|
if (step === target) {
|
|
197
110
|
if (stat.isDirectory()) found.push({ path: step, why: "directory" });
|
|
198
111
|
else found.push({ path: step, why: "exists" });
|
|
@@ -202,25 +115,7 @@ export function collisions(paths, root = null) {
|
|
|
202
115
|
return found;
|
|
203
116
|
}
|
|
204
117
|
|
|
205
|
-
/**
|
|
206
|
-
* The path steps from `root` down to `target`, inclusive of both — or every step to the filesystem root
|
|
207
|
-
* when no root is given.
|
|
208
|
-
*
|
|
209
|
-
* **Where the boundary sits is the whole rule, and getting it wrong was found by running rather than by
|
|
210
|
-
* reading.** The first cut walked to the filesystem root and refused any symlink anywhere on the chain.
|
|
211
|
-
* On macOS `os.tmpdir()` resolves under `/var`, and `/var` is a symlink to `/private/var` — so every
|
|
212
|
-
* scratch directory in this suite was refused, and by extension so was every user whose destination sat
|
|
213
|
-
* under a symlinked mount, a home directory on a linked volume, or `/tmp`.
|
|
214
|
-
*
|
|
215
|
-
* The reason it was wrong is not that the check was too strict; it is that it asked about the wrong
|
|
216
|
-
* paths. A symlink among the **ancestors of the directory the user named** is a fact about their
|
|
217
|
-
* filesystem, and the user named that path — resolving it is not an escape, it is obeying them. A
|
|
218
|
-
* symlink **at or below** the named path is different: those are the segments this tool derives and
|
|
219
|
-
* creates, and a link there is precisely how `init` wrote nine files into an unrelated directory and
|
|
220
|
-
* reported success (session 1, rounds 4 and 6, the write path and then the read path).
|
|
221
|
-
*
|
|
222
|
-
* So the boundary is the named destination. Above it, resolve. At it and below it, refuse.
|
|
223
|
-
*/
|
|
118
|
+
/** `root` down to `target`, both included; links above `root` are the user's own, as macOS `/var` is. */
|
|
224
119
|
function chain(target, root = null) {
|
|
225
120
|
const steps = [];
|
|
226
121
|
let at = target;
|
|
@@ -235,30 +130,13 @@ function chain(target, root = null) {
|
|
|
235
130
|
return steps.reverse();
|
|
236
131
|
}
|
|
237
132
|
|
|
238
|
-
/** A path as a reader would recognise it — relative to cwd when that is shorter, absolute otherwise. */
|
|
239
133
|
function display(target) {
|
|
240
134
|
const rel = path.relative(process.cwd(), target);
|
|
241
135
|
return rel && !rel.startsWith("..") && rel.length < target.length ? rel : target;
|
|
242
136
|
}
|
|
243
137
|
|
|
244
|
-
/**
|
|
245
|
-
* Is this destination inside a `core/` — this checkout's or anybody's?
|
|
246
|
-
*
|
|
247
|
-
* The rule is about the LAYER, not about this repository: a vendored or installed copy of core is still
|
|
248
|
-
* core, and a scaffold landing there is still an edit to a file the project ships. So two questions are
|
|
249
|
-
* asked — is it inside THIS checkout's core, and does the resolved path contain a `core` directory that
|
|
250
|
-
* looks like one (it holds `engine.md` or `templates/`).
|
|
251
|
-
*/
|
|
252
138
|
export function intoCore(resolved) {
|
|
253
|
-
//
|
|
254
|
-
// canonicalise case.** Measured by the pre-commit checkpoint on macOS: `--into <repo>/CORE/templates/x`
|
|
255
|
-
// exited 0 and created a directory inside the shipped `core/`. A case-sensitive compare is not a
|
|
256
|
-
// compare at all on a case-insensitive volume, which is the default on macOS and common on Windows.
|
|
257
|
-
//
|
|
258
|
-
// The cost, stated rather than discovered: on a case-SENSITIVE filesystem a directory genuinely named
|
|
259
|
-
// `CORE` — distinct from `core` — is also refused. That is a conservative refusal, and the asymmetry
|
|
260
|
-
// is the whole argument: refusing costs the user one corrected path, permitting costs an edit to a
|
|
261
|
-
// layer this project ships and the property row 7 states in the imperative.
|
|
139
|
+
// Case-folded: a volume may ignore case and `realpathSync` keeps it, so a genuine `CORE/` is refused too.
|
|
262
140
|
const fold = (p) => p.toLowerCase();
|
|
263
141
|
const ours = path.resolve(CORE);
|
|
264
142
|
if (fold(resolved) === fold(ours) || fold(resolved).startsWith(fold(`${ours}${path.sep}`))) return ours;
|
|
@@ -271,16 +149,6 @@ export function intoCore(resolved) {
|
|
|
271
149
|
return null;
|
|
272
150
|
}
|
|
273
151
|
|
|
274
|
-
/**
|
|
275
|
-
* Parse the command line.
|
|
276
|
-
*
|
|
277
|
-
* A value beginning with **any** leading `-` is a missing value, not a value — session 1's round 9,
|
|
278
|
-
* where `--residence -h` was consumed as the residence and the user was then blamed for a token they
|
|
279
|
-
* typed as a flag. `-h` is the likeliest thing to land there and it is a help request.
|
|
280
|
-
*
|
|
281
|
-
* An empty string is refused for every flag, because none of them has a meaningful empty value and an
|
|
282
|
-
* empty one reached a manifest once already, failing `minLength: 1` on the run that reported success.
|
|
283
|
-
*/
|
|
284
152
|
export function parseArgs(argv) {
|
|
285
153
|
const out = { kind: null, name: null, into: null, category: null, kindOf: null, governedBy: null, help: false, given: new Set() };
|
|
286
154
|
const positional = [];
|
|
@@ -338,11 +206,7 @@ function usage() {
|
|
|
338
206
|
].join("\n");
|
|
339
207
|
}
|
|
340
208
|
|
|
341
|
-
/**
|
|
342
|
-
* What a kind writes: a list of `{ path, contents }`. Multi-file kinds are planned in one place so the
|
|
343
|
-
* collision check sees **every** path before the first byte is written — a partial write that wedges its
|
|
344
|
-
* own retry is what session 1 shipped when the manifest went out first.
|
|
345
|
-
*/
|
|
209
|
+
/** Every file a kind writes, planned whole so the collision check sees each path before the first byte. */
|
|
346
210
|
export function plan(parsed) {
|
|
347
211
|
const { kind, name } = parsed;
|
|
348
212
|
const into = path.resolve(parsed.into ?? process.cwd());
|
|
@@ -386,7 +250,6 @@ export function plan(parsed) {
|
|
|
386
250
|
return [{ path: destination(kind, name, into), contents: fill(body, { name }) }];
|
|
387
251
|
}
|
|
388
252
|
|
|
389
|
-
/** Substitutes the placeholders a scaffold can honestly know. Everything else stays in `{braces}`. */
|
|
390
253
|
function fill(body, values) {
|
|
391
254
|
let out = body;
|
|
392
255
|
if (values.name !== undefined) out = out.replaceAll("{kebab-case-name}", values.name);
|
|
@@ -414,33 +277,9 @@ function packReadme(name, category) {
|
|
|
414
277
|
].join("\n");
|
|
415
278
|
}
|
|
416
279
|
|
|
417
|
-
/**
|
|
418
|
-
* A workspace scaffold that is **green under `doctor` on the run after it is written**.
|
|
419
|
-
*
|
|
420
|
-
* That is a deliberate departure from what `init` does, and the reason is that the two tools are in
|
|
421
|
-
* different situations rather than that one of them is wrong. `init` composes a checkpoints pack by
|
|
422
|
-
* default because row 7 clause (a) says the cycle arrives opt-**out**, and nothing discovers a pack — so
|
|
423
|
-
* its draft is red until a root is named, loudly and on purpose. `new workspace` composes no pack: it is
|
|
424
|
-
* for a workspace somebody is authoring by hand, where a red first run would be a red the author did not
|
|
425
|
-
* cause and cannot act on.
|
|
426
|
-
*
|
|
427
|
-
* `portfolio` is the default kind for the same reason. A `repository` workspace must declare `tree`, and
|
|
428
|
-
* a scaffold that guesses which tree it governs would be a claim about the filesystem that the scaffold
|
|
429
|
-
* cannot check — the claims lint would then grade a repository nobody chose.
|
|
430
|
-
*/
|
|
431
280
|
function workspaceFiles(root, name, wkind, body, governedBy = null) {
|
|
432
|
-
// A POINTER carries `governed_by` and NONE of the governing keys — no slots, no verify, no gates.
|
|
433
|
-
// The Workspace Definition requires the first and `doctor` refuses the second, so a pointer built
|
|
434
|
-
// from the governing shape is red on the run after it is written. Copilot's suppressed notes on #156
|
|
435
|
-
// caught it, twice on one file: the scaffolder always wrote the governing shape while the help screen
|
|
436
|
-
// advertised `pointer` as a supported kind. That is this session's own recurring defect for the third
|
|
437
|
-
// time — a scaffold failing the validation that lives beside it — so it is fixed rather than triaged.
|
|
438
281
|
if (wkind === "pointer") {
|
|
439
|
-
// 2.7
|
|
440
|
-
// needs 2.7 — the version that added the kind. Nothing scaffolded here declares a key from a
|
|
441
|
-
// later MINOR, so claiming one would narrow the manifest against adopter tooling pinned at 2.7
|
|
442
|
-
// for a contract it does not use. Same rule as `cli/init.mjs`'s and `cli/vendor.mjs`'s `SPEC`,
|
|
443
|
-
// and as the `GATE_POLICY_SPEC = "2.2"` beside them.
|
|
282
|
+
// 2.7 added the pointer kind; a writer declares the version its output needs, not the newest.
|
|
444
283
|
const manifest = { portulan: { spec: "2.7" }, name, kind: "pointer", governed_by: { workspace: governedBy } };
|
|
445
284
|
return [
|
|
446
285
|
{ path: path.join(root, "workspace.json"), contents: `${JSON.stringify(manifest, null, 2)}\n` },
|
|
@@ -462,21 +301,12 @@ function workspaceFiles(root, name, wkind, body, governedBy = null) {
|
|
|
462
301
|
}
|
|
463
302
|
|
|
464
303
|
const manifest = {
|
|
465
|
-
// The version this output
|
|
304
|
+
// The version this output needs, not the newest.
|
|
466
305
|
portulan: { spec: "2.7" },
|
|
467
306
|
name,
|
|
468
307
|
summary: `{One line: whose workspace this is and what it governs.}`,
|
|
469
308
|
kind: wkind,
|
|
470
|
-
// `repos` is declared and its directory scaffolded so the six kinds compose end-to-end from
|
|
471
|
-
// `new` alone: without it, `new repo-card --into <a workspace new just wrote>` exits 2 because
|
|
472
|
-
// the manifest declares nowhere for a card to land. A tool whose own output cannot host its own
|
|
473
|
-
// next command is a tool with a seam in the middle of it.
|
|
474
309
|
slots: { identity: "identity.md", principles: "principles.md", gates: "gate-map.md", repos: "repos/" },
|
|
475
|
-
// The schema requires a `verify` block on every workspace that is not a pointer, so this is not
|
|
476
|
-
// optional and a scaffold omitting it is red on the run after it is written. What it must NOT be
|
|
477
|
-
// is a stub exiting 0: the author has not said what green means here yet, and a false green under
|
|
478
|
-
// every gate from the day the workspace was created is the worst artifact this tool could emit.
|
|
479
|
-
// Exit 2 — could not run — is the honest state, and it is the same answer `init` reached.
|
|
480
310
|
verify: {
|
|
481
311
|
default: "unset",
|
|
482
312
|
recipes: [{ id: "unset", run: "./verify/unset.sh", requires: ["bash"], doc: "verify/README.md" }],
|
|
@@ -496,15 +326,6 @@ function workspaceFiles(root, name, wkind, body, governedBy = null) {
|
|
|
496
326
|
];
|
|
497
327
|
}
|
|
498
328
|
|
|
499
|
-
/**
|
|
500
|
-
* The recipe a scaffolded workspace starts with: one that **exits 2**, meaning *could not run*.
|
|
501
|
-
*
|
|
502
|
-
* It is deliberately not a stub exiting 0. A verify recipe is what decides what *done* means for a
|
|
503
|
-
* workspace, the Stop-gate runs the default, and a recipe returning green before anybody has said what
|
|
504
|
-
* green means would put a false pass under every gate the workspace has — on day one, in the artifact
|
|
505
|
-
* meant to welcome the author. The three-code discipline exists exactly for this: a recipe that cannot
|
|
506
|
-
* run must never be indistinguishable from one that ran and passed.
|
|
507
|
-
*/
|
|
508
329
|
function unsetRecipe(name) {
|
|
509
330
|
return [
|
|
510
331
|
"#!/usr/bin/env bash",
|
|
@@ -611,8 +432,6 @@ export function run(argv, options = {}) {
|
|
|
611
432
|
|
|
612
433
|
const into = path.resolve(parsed.into ?? process.cwd());
|
|
613
434
|
|
|
614
|
-
// Refused before the template is even read: the destination is the question, and a `core/` answer
|
|
615
|
-
// is refused whether it was reached by name, by `..`, or by a link.
|
|
616
435
|
const linkAt = firstLink(into);
|
|
617
436
|
if (linkAt) {
|
|
618
437
|
say(
|
|
@@ -623,9 +442,6 @@ export function run(argv, options = {}) {
|
|
|
623
442
|
}
|
|
624
443
|
const core = intoCore(realOrSelf(into));
|
|
625
444
|
if (core) {
|
|
626
|
-
// Phrased against the RESOLVED destination rather than the argument as typed, because the two
|
|
627
|
-
// differ in exactly the cases worth naming — `packs/../core` and a link — and a refusal that
|
|
628
|
-
// echoes the argument back tells the reader nothing they did not already type.
|
|
629
445
|
const resolved = realOrSelf(into);
|
|
630
446
|
say(
|
|
631
447
|
`new: that destination resolves to ${resolved === core ? `\`${core}\`` : `${resolved}, inside \`${core}\``}, ` +
|
|
@@ -643,9 +459,7 @@ export function run(argv, options = {}) {
|
|
|
643
459
|
return 2;
|
|
644
460
|
}
|
|
645
461
|
|
|
646
|
-
// The manifest
|
|
647
|
-
// torso that a later run reads as a finished artifact — session 1's partial write, which wedged
|
|
648
|
-
// its own retry and refused it with a sentence that was false.
|
|
462
|
+
// The manifest last, so a failure mid-loop leaves nothing a later run reads as finished.
|
|
649
463
|
const ordered = [...files].sort((a, b) => Number(manifestish(a.path)) - Number(manifestish(b.path)));
|
|
650
464
|
for (const file of ordered) {
|
|
651
465
|
fs.mkdirSync(path.dirname(file.path), { recursive: true });
|
|
@@ -656,10 +470,6 @@ export function run(argv, options = {}) {
|
|
|
656
470
|
for (const file of files) say(` ${display(file.path)}`);
|
|
657
471
|
say("");
|
|
658
472
|
say(`It is a draft — the placeholders in \`{braces}\` are yours to fill. Then: portulan doctor <workspace-dir>`);
|
|
659
|
-
// What a scaffold CANNOT do for you, said at the moment it matters rather than in a README nobody
|
|
660
|
-
// opens next. This tool never edits a file it did not write — a manifest is a curated layer, and
|
|
661
|
-
// a generator that rewrites one is the generator that eventually rewrites the wrong one — so the
|
|
662
|
-
// wiring step is the author's, and leaving it unsaid would produce a file nothing reads.
|
|
663
473
|
for (const line of wiring(parsed.kind, parsed.name, parsed.category, into)) say(line);
|
|
664
474
|
return 0;
|
|
665
475
|
} catch (error) {
|
|
@@ -670,24 +480,12 @@ export function run(argv, options = {}) {
|
|
|
670
480
|
|
|
671
481
|
const manifestish = (p) => /(?:workspace|pack)\.json$/.test(p);
|
|
672
482
|
|
|
673
|
-
/**
|
|
674
|
-
* The step a scaffold cannot take for the author: declaring the thing it just wrote.
|
|
675
|
-
*
|
|
676
|
-
* Nothing here edits a manifest the tool did not write in the same run. That is deliberate and it is the
|
|
677
|
-
* same rule `init` settled — a manifest is part of the curated layer, and a generator that edits one is
|
|
678
|
-
* the generator that eventually edits the wrong one. The cost of that rule is a file nothing references,
|
|
679
|
-
* so the cost is paid out loud, here, at the moment the file lands.
|
|
680
|
-
*/
|
|
483
|
+
/** Said rather than done: nothing here edits a manifest it did not write in the same run. */
|
|
681
484
|
function wiring(kind, name, category, target) {
|
|
682
485
|
switch (kind) {
|
|
683
486
|
case "gate-policy":
|
|
684
487
|
return ["", `Nothing reads it yet: add \`"gates": "${name}.json"\` to the workspace's \`workspace.json\`, then \`portulan compile\`.`];
|
|
685
488
|
case "skill": {
|
|
686
|
-
// **Say only what was actually checked.** The previous version read whether a `pack.json`
|
|
687
|
-
// EXISTED and then made a claim about what it DECLARES — which is the same defect one level
|
|
688
|
-
// down: a pack declaring `contributes.skills: ["parts/"]` was told `skills/` is "already
|
|
689
|
-
// declared", and a skill landing under a declared root was told nothing declares it. Found
|
|
690
|
-
// by review, inside the fix for that exact class.
|
|
691
489
|
let roots = null;
|
|
692
490
|
try {
|
|
693
491
|
roots = JSON.parse(fs.readFileSync(path.join(target ?? ".", "pack.json"), "utf8"))?.contributes?.skills ?? [];
|
|
@@ -713,20 +511,7 @@ function wiring(kind, name, category, target) {
|
|
|
713
511
|
}
|
|
714
512
|
}
|
|
715
513
|
|
|
716
|
-
/**
|
|
717
|
-
* The destination as the filesystem actually sees it, **including when it does not exist yet**.
|
|
718
|
-
*
|
|
719
|
-
* The first cut was `try { realpathSync(t) } catch { return path.resolve(t) }` — and the catch is not
|
|
720
|
-
* the rare branch, it is the **ordinary** one: a scaffolding tool's destination usually does not exist.
|
|
721
|
-
* So whenever the leaf was new, this returned an unresolved path and the `core/` check graded a string
|
|
722
|
-
* the filesystem had never confirmed. Measured by the pre-commit checkpoint: a symlink whose target is
|
|
723
|
-
* `core/`, plus a leaf one level below it, wrote into the shipped `core/` and exited 0. The file header
|
|
724
|
-
* two hundred lines up promised "the check resolves first and compares after"; it did, only when the
|
|
725
|
-
* destination already existed, which is the case that matters least.
|
|
726
|
-
*
|
|
727
|
-
* So: realpath the nearest **existing** ancestor and re-append the unresolved tail. That resolves every
|
|
728
|
-
* link on the part of the path that exists — which is the only part a link can be on.
|
|
729
|
-
*/
|
|
514
|
+
/** The realpath of the nearest existing ancestor, with the part not created yet appended. */
|
|
730
515
|
function realOrSelf(target) {
|
|
731
516
|
let head = path.resolve(target);
|
|
732
517
|
const tail = [];
|
|
@@ -734,8 +519,6 @@ function realOrSelf(target) {
|
|
|
734
519
|
try {
|
|
735
520
|
return tail.length ? path.join(fs.realpathSync(head), ...tail) : fs.realpathSync(head);
|
|
736
521
|
} catch (cause) {
|
|
737
|
-
// Only ENOENT means "not there yet, keep walking up". Anything else means the question could
|
|
738
|
-
// not be answered, and an unanswerable question must not be answered "fine".
|
|
739
522
|
if (cause.code !== "ENOENT") {
|
|
740
523
|
throw new NewError(
|
|
741
524
|
`${display(head)} could not be resolved — ${cause.code ?? cause.message}. Refusing rather than grading a path nothing confirmed`,
|
|
@@ -750,29 +533,15 @@ function realOrSelf(target) {
|
|
|
750
533
|
return path.resolve(target);
|
|
751
534
|
}
|
|
752
535
|
|
|
753
|
-
/**
|
|
754
|
-
* Is the destination the user named itself a symlink?
|
|
755
|
-
*
|
|
756
|
-
* Only the named path is asked about here, not its ancestors — see `chain` for why that boundary is the
|
|
757
|
-
* rule rather than a convenience. A user who points `--into` at a link is refused rather than followed,
|
|
758
|
-
* because deciding whether a link's target is "really" where they meant is a containment judgement with
|
|
759
|
-
* a bad failure mode, where refusing has none and costs one `realpath` of typing.
|
|
760
|
-
*/
|
|
761
536
|
function firstLink(target) {
|
|
762
537
|
try {
|
|
763
538
|
return fs.lstatSync(target).isSymbolicLink() ? target : null;
|
|
764
539
|
} catch (cause) {
|
|
765
|
-
// Only ENOENT means absent — a destination that does not exist yet is the ordinary case here.
|
|
766
540
|
if (cause.code === "ENOENT") return null;
|
|
767
541
|
throw new NewError(`${display(target)} could not be read — ${cause.code ?? cause.message}. Only a missing path means "nothing there", so nothing was written`);
|
|
768
542
|
}
|
|
769
543
|
}
|
|
770
544
|
|
|
771
|
-
/**
|
|
772
|
-
* One line per cause, not one per path. Session 1 learned this the hard way: a single symlinked
|
|
773
|
-
* directory made the refusal name the same cause ten times, and a refusal nobody finishes reading is a
|
|
774
|
-
* refusal that failed to explain.
|
|
775
|
-
*/
|
|
776
545
|
function groupByCause(clash) {
|
|
777
546
|
const by = new Map();
|
|
778
547
|
for (const item of clash) {
|
package/cli/pack-identity.mjs
CHANGED
|
@@ -1,37 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Every file `npm pack` emits is byte-identical to the tracked blob it came from.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
// can actually run on every pull request. `.portulan/identity.md` states the property — **the `npx`
|
|
6
|
-
// path IS an install, and it installs the SAME BYTES** — and until 2026-08-18 nothing checked it: the
|
|
7
|
-
// figure was re-measured by hand three times (72 files, then 114, then 73) and was wrong in the tree
|
|
8
|
-
// twice between those measurements.
|
|
9
|
-
//
|
|
10
|
-
// ## Why this compares against the TREE and not against the registry
|
|
11
|
-
//
|
|
12
|
-
// The measurement taken at the first publish compared the published tarball to a fresh `npm pack`, and
|
|
13
|
-
// they hashed the same. That is the stronger claim and it is recorded on #149 — but it is the wrong
|
|
14
|
-
// shape for a recipe: it needs the network, it needs a published version to exist, and it cannot run
|
|
15
|
-
// before the first publish or on a machine offline. A recipe that cannot run is a recipe that stops
|
|
16
|
-
// being run.
|
|
17
|
-
//
|
|
18
|
-
// What this checks instead needs neither: `npm pack --dry-run --json` lists what the package would
|
|
19
|
-
// contain, and every one of those paths must be byte-identical to its STAGED blob, `git show :<path>`
|
|
20
|
-
// — the index rather than HEAD, for the reason the comment on `blobAt` gives. That is the
|
|
21
|
-
// property the registry comparison DEPENDS on — if the packed bytes match the tree, and the published
|
|
22
|
-
// tarball matches a fresh pack, then the published bytes are the tree's. This rail owns the first half,
|
|
23
|
-
// which is the half that can drift silently on any commit; the second half is a hand measurement taken
|
|
24
|
-
// at each publish, which is what #149 asked for and what its comment records.
|
|
25
|
-
//
|
|
26
|
-
// ## Exit codes, per ../.portulan/memory/verify-preconditions-fail-closed.md
|
|
27
|
-
//
|
|
28
|
-
// 0 every packed file matches its staged blob
|
|
29
|
-
// 1 a finding: at least one packed file differs from what is staged, or is not tracked at all
|
|
30
|
-
// 2 could not run: `npm pack` unusable, not a git repository, or HEAD unreadable
|
|
31
|
-
//
|
|
32
|
-
// The 2 matters more here than in most recipes. `npm pack` shells out to npm, which can be absent,
|
|
33
|
-
// offline-hostile, or a different major version — and a rail that reports "the bytes differ" when what
|
|
34
|
-
// happened is "npm did not run" sends someone hunting a drift that does not exist.
|
|
4
|
+
// Exit 0 every packed file matches its staged blob · 1 one differs or is untracked · 2 could not run.
|
|
35
5
|
|
|
36
6
|
import { execFileSync } from "node:child_process";
|
|
37
7
|
import fs from "node:fs";
|
|
@@ -43,14 +13,7 @@ class CannotRun extends Error {}
|
|
|
43
13
|
|
|
44
14
|
const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024, ...opts });
|
|
45
15
|
|
|
46
|
-
/**
|
|
47
|
-
* What the package would contain, as repo-relative paths.
|
|
48
|
-
*
|
|
49
|
-
* **Exported because `./payload.mjs` classifies exactly this roster**, and two enumerations of *what
|
|
50
|
-
* npm would pack* is the second-carrier defect this repository keeps paying for. That rail asks which
|
|
51
|
-
* files ship; this one asks whether each is byte-identical to its source. One question about
|
|
52
|
-
* membership, asked once, answered here.
|
|
53
|
-
*/
|
|
16
|
+
/** The paths `npm pack` would ship, relative to `root`. */
|
|
54
17
|
export function packedPaths(root) {
|
|
55
18
|
let out;
|
|
56
19
|
try {
|
|
@@ -66,10 +29,6 @@ export function packedPaths(root) {
|
|
|
66
29
|
}
|
|
67
30
|
const files = parsed?.[0]?.files;
|
|
68
31
|
if (!Array.isArray(files) || files.length === 0) throw new CannotRun("`npm pack` reported no files — refusing to report a green over an empty roster");
|
|
69
|
-
// The SHAPE is checked, not assumed. `files` is npm's JSON, and a changed schema or an odd entry
|
|
70
|
-
// would otherwise yield `undefined` paths that crash further down — surfacing as an uncaught
|
|
71
|
-
// exception the runner reads as a red FINDING about the package. npm's output is an input like any
|
|
72
|
-
// other; a roster this tool cannot read is could-not-run.
|
|
73
32
|
const paths = [];
|
|
74
33
|
for (const [i, entry] of files.entries()) {
|
|
75
34
|
const rel = entry?.path;
|
|
@@ -81,13 +40,7 @@ export function packedPaths(root) {
|
|
|
81
40
|
return paths;
|
|
82
41
|
}
|
|
83
42
|
|
|
84
|
-
|
|
85
|
-
// usability of this rail. `npm pack` reads the WORKING TREE, so comparing it to HEAD makes the recipe red
|
|
86
|
-
// for any uncommitted edit — and `.portulan/dod.md` requires every recipe green BEFORE a commit, which a
|
|
87
|
-
// check that can only pass after one contradicts. The index is what is about to become the commit, so
|
|
88
|
-
// staged work compares clean and unstaged work is correctly still a difference. In CI the distinction
|
|
89
|
-
// vanishes: `actions/checkout` leaves the index matching HEAD, so this reads exactly as HEAD there.
|
|
90
|
-
/** The staged blob for a path, or null when the path is not tracked. */
|
|
43
|
+
/** The staged blob, not HEAD's, so staged work compares clean before its commit; null when the path is not tracked. */
|
|
91
44
|
function blobAt(root, rel) {
|
|
92
45
|
try {
|
|
93
46
|
return run("git", ["show", `:${rel}`], { cwd: root, stdio: ["ignore", "pipe", "ignore"] });
|
|
@@ -96,17 +49,6 @@ function blobAt(root, rel) {
|
|
|
96
49
|
}
|
|
97
50
|
}
|
|
98
51
|
|
|
99
|
-
// Every path npm reports is checked for CONTAINMENT before it is used, by RESOLUTION rather than by
|
|
100
|
-
// pattern — the shape `cli/eval-bundle.mjs` was hardened to on #280, for the same reason: a pattern
|
|
101
|
-
// test answers a question about a string, and what matters is where the filesystem actually goes.
|
|
102
|
-
// `rel` arrives from `npm pack`'s JSON, so it is not this tool's to trust: an absolute path, a `..`
|
|
103
|
-
// escape or an embedded NUL would send both `git show` and the read outside the repository, and the
|
|
104
|
-
// rail would then compare the wrong bytes and report GREEN. Refused as could-not-run, never as a
|
|
105
|
-
// finding — a path this tool will not resolve supports no verdict about the package.
|
|
106
|
-
//
|
|
107
|
-
// Symlinks are refused for the same reason and separately from containment: `readFileSync` follows
|
|
108
|
-
// them, so a link whose target sits outside the tree reads bytes git never carried while `git show`
|
|
109
|
-
// reads the link itself. `lstat` is what distinguishes them, and `realpath` alone would not.
|
|
110
52
|
function containedPath(root, rel) {
|
|
111
53
|
if (rel.includes("\0")) throw new CannotRun(`npm reported a path containing NUL — refusing to resolve it`);
|
|
112
54
|
if (path.isAbsolute(rel)) throw new CannotRun(`npm reported an absolute path (${rel}); packed paths are repo-relative`);
|
|
@@ -136,16 +78,10 @@ export function compare(root) {
|
|
|
136
78
|
const untracked = [];
|
|
137
79
|
const differing = [];
|
|
138
80
|
for (const rel of packed) {
|
|
139
|
-
// `package.json`
|
|
140
|
-
// every other file rather than exempted: the 2026-08-13 measurement asserted all 114 identical
|
|
141
|
-
// "package.json included, with no exemption", and an exemption added here would quietly retire
|
|
142
|
-
// that claim.
|
|
81
|
+
// `package.json` too, though npm may normalise it on pack: the same-bytes claim takes no exemption.
|
|
143
82
|
const abs = containedPath(root, rel);
|
|
144
83
|
const blob = blobAt(root, rel);
|
|
145
84
|
if (blob === null) untracked.push(rel);
|
|
146
|
-
// Read with `fs`, not by shelling out. `cat` would be an undeclared dependency this recipe's
|
|
147
|
-
// `requires` does not list, and it takes its argument as an OPTION when a filename begins with
|
|
148
|
-
// `-` — so a file named `-n` would silently be read as a flag rather than compared.
|
|
149
85
|
else if (!blob.equals(fs.readFileSync(abs))) differing.push(rel);
|
|
150
86
|
}
|
|
151
87
|
return { packed, untracked, differing };
|
|
@@ -161,10 +97,7 @@ function main(argv, stdout, stderr) {
|
|
|
161
97
|
stderr.write(`pack-identity: could not run — ${error.message}\n`);
|
|
162
98
|
return 2;
|
|
163
99
|
}
|
|
164
|
-
//
|
|
165
|
-
// — node's own code, typically 1 — and the runner would read a crash in this rail as a verdict
|
|
166
|
-
// about the package's bytes. `1` is reserved for a finding; this repository has a commit by that
|
|
167
|
-
// name. The diagnostic names the rail so the exit is traceable to the thing that broke.
|
|
100
|
+
// Rethrown, a crash would exit 1, which reads as a finding.
|
|
168
101
|
stderr.write(`pack-identity: could not run — unexpected ${error?.name ?? "error"}: ${String(error?.message ?? error).split("\n")[0]}\n`);
|
|
169
102
|
return 2;
|
|
170
103
|
}
|
|
@@ -179,8 +112,6 @@ function main(argv, stdout, stderr) {
|
|
|
179
112
|
return 1;
|
|
180
113
|
}
|
|
181
114
|
|
|
182
|
-
// The same guard `pack-version.mjs` uses. A bare path comparison is FALSE when npm installs a bin as a
|
|
183
|
-
// symlink — node realpaths the main module for `import.meta.url` while `process.argv[1]` keeps the link.
|
|
184
115
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
185
116
|
process.exitCode = main(process.argv, process.stdout, process.stderr);
|
|
186
117
|
}
|