@sleepy_panda_srl/portulan 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -6
- package/cli/README.md +142 -291
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1443 -1210
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +225 -1071
- package/cli/drills.mjs +1107 -0
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +595 -0
- package/cli/gate.mjs +7 -198
- package/cli/goldens.mjs +311 -0
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +244 -564
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +778 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +764 -0
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +6 -68
- package/cli/pack-version.mjs +13 -248
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +5 -124
- package/cli/recipe-set.mjs +16 -240
- package/cli/release-eval.mjs +748 -0
- package/cli/review-meter.mjs +500 -0
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +424 -0
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +126 -454
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +582 -0
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +9 -74
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +183 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/consolidate/SKILL.md +4 -2
- package/core/templates/gate-policy.md +27 -8
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +320 -0
- package/evals/releases/0.1.3.json +134 -0
- package/evals/releases/0.1.3.md +64 -0
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/README.md +32 -0
- package/package.json +9 -2
- package/packs/rituals/checkpoints/README.md +9 -2
- package/packs/rituals/checkpoints/pack.json +1 -1
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +32 -2
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/spec/README.md +112 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +359 -23
- package/spec/workspace.schema.json +145 -4
|
@@ -1,30 +1,11 @@
|
|
|
1
1
|
// Workspace Definition 1.0 → 2.0 — a `repository` workspace declares its `tree`.
|
|
2
|
-
//
|
|
3
|
-
// The only MAJOR migration this train has, and `../README.md` records why it was taken while it cost
|
|
4
|
-
// nothing: `tree` began optional, and optional was a hole — deleting one manifest line degraded the
|
|
5
|
-
// whole claims-lint class from *checked* to *reported*, GREEN, exit 0.
|
|
6
|
-
//
|
|
7
|
-
// **This step has no subject in this repository and none in any tree we have seen.** Nothing here
|
|
8
|
-
// declares 1.0. It is exercised against a fixture, which `../README.md` says out loud rather than
|
|
9
|
-
// leaving a passing suite to imply otherwise.
|
|
10
2
|
|
|
11
3
|
import fs from "node:fs";
|
|
12
4
|
import path from "node:path";
|
|
13
5
|
|
|
14
|
-
/** The version this step migrates FROM, as a MAJOR. One place, so the two readers below agree. */
|
|
15
6
|
const FROM_MAJOR = 1;
|
|
16
7
|
|
|
17
|
-
/**
|
|
18
|
-
* Is `dir` a repository root? `.git` is the evidence, and it is **a file as often as a directory**.
|
|
19
|
-
*
|
|
20
|
-
* A worktree's `.git` is a FILE containing `gitdir: …`, and so is a submodule's. A directory-only
|
|
21
|
-
* test passes every suite written on an ordinary clone and is wrong for every worktree user — the
|
|
22
|
-
* shape this project keeps paying for, so it is guarded here rather than discovered. The repository
|
|
23
|
-
* this step ships from is itself checked out as a worktree today.
|
|
24
|
-
*
|
|
25
|
-
* Three answers, not two: `yes`, `no`, and **`unknown`** — because only `ENOENT` means absent, and an
|
|
26
|
-
* `EACCES` is a question nobody answered.
|
|
27
|
-
*/
|
|
8
|
+
/** `.git` is a file in a worktree or a submodule, and only ENOENT means it is absent. */
|
|
28
9
|
function repositoryRoot(dir) {
|
|
29
10
|
try {
|
|
30
11
|
fs.lstatSync(path.join(dir, ".git"));
|
|
@@ -63,16 +44,10 @@ export const step = {
|
|
|
63
44
|
plan(ws, ctx) {
|
|
64
45
|
const next = { ...ws.manifest, portulan: { ...ws.manifest.portulan, spec: this.to } };
|
|
65
46
|
|
|
66
|
-
// Only a `repository` needs `tree`. A `demo` or `portfolio` describes repositories that are
|
|
67
|
-
// not beside it, and giving one a tree would be inventing a claim the workspace never made.
|
|
68
47
|
if (next.kind === "repository" && typeof next.tree !== "string") {
|
|
69
48
|
if (typeof ctx.tree === "string" && ctx.tree !== "") {
|
|
70
49
|
next.tree = ctx.tree;
|
|
71
50
|
} else {
|
|
72
|
-
// Derived only where it is derivable, and refused otherwise. A hopeful `../` is a
|
|
73
|
-
// claim about somebody's layout made by a tool that did not look — and `tree` is
|
|
74
|
-
// precisely the slot whose wrongness turns a check class into a lie rather than an
|
|
75
|
-
// error.
|
|
76
51
|
const parent = path.resolve(ws.dir, "..");
|
|
77
52
|
const verdict = repositoryRoot(parent);
|
|
78
53
|
if (verdict === "yes") {
|
|
@@ -1,84 +1,36 @@
|
|
|
1
1
|
// A repair — the bundle path a rewriter owes a workspace it touches.
|
|
2
|
-
//
|
|
3
|
-
// Since milestone 7 session 7, `cli/init.mjs` drafts `verify/index.sh` with a third CLI location baked
|
|
4
|
-
// in: the bundle `init` ran from, as an ABSOLUTE path that git cannot carry anywhere. It is a
|
|
5
|
-
// deliberate convenience for the person who ran `init`, and it is true on exactly one machine.
|
|
6
|
-
//
|
|
7
|
-
// **The failure is quiet, which is why it needs a tool rather than a reminder.** `vendor --switch`
|
|
8
|
-
// copies these files byte for byte into another residence; a clone lands on a machine that never had
|
|
9
|
-
// that bundle. The rail then exits **2 — could not run**, which is the fail-closed direction and is
|
|
10
|
-
// therefore easy to never notice: nothing goes red, the index simply stops being checked.
|
|
11
|
-
//
|
|
12
|
-
// **The marker is the contract.** `cli/init.mjs` writes `# portulan:bundle-fallback` on both lines
|
|
13
|
-
// carrying the path precisely so a rewriter can FIND them rather than having to notice a comment, and
|
|
14
|
-
// `cli/init.test.mjs` asserts the marker survives edits to the recipe. This step is the rewriter that
|
|
15
|
-
// note was written for.
|
|
16
2
|
|
|
17
3
|
import path from "node:path";
|
|
18
4
|
import { fileURLToPath } from "node:url";
|
|
19
5
|
|
|
20
|
-
/** The token `cli/init.mjs` writes
|
|
6
|
+
/** The token `cli/init.mjs` writes on each line that carries the bundle path. */
|
|
21
7
|
export const MARKER = "portulan:bundle-fallback";
|
|
22
8
|
|
|
23
|
-
/** The entry point a marked line points at, relative to a bundle root. */
|
|
24
9
|
const ENTRY = ["cli", "index.mjs"];
|
|
25
10
|
|
|
26
|
-
/**
|
|
27
|
-
* Is this file one the marker could be *marking*, as opposed to one that talks about the marker?
|
|
28
|
-
*
|
|
29
|
-
* **Found by running this step against this repository rather than against its own fixtures.** The
|
|
30
|
-
* marker is a token, and a token appears in prose: `.portulan/handoffs/2026-08-11-…md` documents the
|
|
31
|
-
* marker in a sentence, and the first cut of this step read that paragraph as a marked line of a
|
|
32
|
-
* shape it did not recognise and refused to migrate the workspace at all — exit 2, on the one
|
|
33
|
-
* workspace this repository actually owns. Every unit test passed, because no fixture here had ever
|
|
34
|
-
* written about itself.
|
|
35
|
-
*
|
|
36
|
-
* What a repair fixes is a **runnable fallback**, so the scope is scripts: a shell shebang, or a
|
|
37
|
-
* `.sh` name. That is narrower than "any file carrying the token" and it is the honest reading of
|
|
38
|
-
* what `cli/init.mjs` writes — it is deliberately NOT narrowed to `verify/index.sh`, because keying
|
|
39
|
-
* on the filename would make a second and narrower carrier of the rule the marker exists to be.
|
|
40
|
-
*/
|
|
11
|
+
/** A script, by name or shebang: prose that mentions the marker marks nothing. */
|
|
41
12
|
function couldCarryTheRail(rel, text) {
|
|
42
13
|
if (rel.endsWith(".sh")) return true;
|
|
43
14
|
const first = text.slice(0, text.indexOf("\n") + 1 || undefined);
|
|
44
15
|
return first.startsWith("#!") && /\b(?:ba|da|k|z)?sh\b/.test(first);
|
|
45
16
|
}
|
|
46
17
|
|
|
47
|
-
|
|
18
|
+
function isMarkedScript(rel, text) {
|
|
19
|
+
return text.includes(MARKER) && couldCarryTheRail(rel, text);
|
|
20
|
+
}
|
|
21
|
+
|
|
48
22
|
const OWN_BUNDLE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
49
23
|
|
|
50
|
-
/** What a marked line should name, for a given bundle root. */
|
|
51
24
|
function entryFor(bundle) {
|
|
52
25
|
return `${bundle}/${ENTRY.join("/")}`;
|
|
53
26
|
}
|
|
54
27
|
|
|
55
|
-
/**
|
|
56
|
-
* The one JSON string literal on a marked line that names a CLI entry point.
|
|
57
|
-
*
|
|
58
|
-
* Returns `null` when the line is not the shape `init` writes — which is a **refusal**, never a
|
|
59
|
-
* best-effort rewrite. A marked line this step does not recognise is somebody's edit, and rewriting
|
|
60
|
-
* around it by pattern would be exactly the guesswork `../README.md` says a repair may not do.
|
|
61
|
-
*/
|
|
62
28
|
function quotedEntry(line) {
|
|
63
29
|
const matches = [...line.matchAll(/"((?:[^"\\]|\\.)*)"/g)].filter((m) => m[1].endsWith(`/${ENTRY.join("/")}`));
|
|
64
30
|
return matches.length === 1 ? matches[0] : null;
|
|
65
31
|
}
|
|
66
32
|
|
|
67
|
-
/**
|
|
68
|
-
* Every marked line in `text`, paired with the entry path it names — or a reason it is unreadable.
|
|
69
|
-
*
|
|
70
|
-
* **`quoted` is the DECODED path, not the source text between the quotes.** The regex captures what
|
|
71
|
-
* is written in the file, which is still JSON-escaped: `init` emits `JSON.stringify(...)`, so a
|
|
72
|
-
* bundle at `C:\Users\x` appears as `"C:\\Users\\x/cli/index.mjs"`. Comparing that raw capture to an
|
|
73
|
-
* unescaped `want` never matches, and the step reports itself **perpetually owed** — rewriting the
|
|
74
|
-
* same file on every run, forever.
|
|
75
|
-
*
|
|
76
|
-
* Invisible on POSIX, where an ordinary path contains nothing JSON escapes, which is why every test
|
|
77
|
-
* and every live run here passed over it. Copilot's promoted note, round 4 on #231.
|
|
78
|
-
*
|
|
79
|
-
* A literal that will not decode is treated as unreadable rather than guessed at, which is the same
|
|
80
|
-
* refusal an unrecognised marked line already gets.
|
|
81
|
-
*/
|
|
33
|
+
/** Each marked line's entry path, JSON-decoded, since the raw capture keeps the escapes `init` wrote. */
|
|
82
34
|
function marked(text) {
|
|
83
35
|
const lines = text.split("\n");
|
|
84
36
|
const hits = [];
|
|
@@ -92,7 +44,7 @@ function marked(text) {
|
|
|
92
44
|
} catch {
|
|
93
45
|
return { ok: false, line: n + 1, text: line };
|
|
94
46
|
}
|
|
95
|
-
hits.push({ n,
|
|
47
|
+
hits.push({ n, entry: decoded, whole: found[0] });
|
|
96
48
|
}
|
|
97
49
|
return { ok: true, lines, hits };
|
|
98
50
|
}
|
|
@@ -116,17 +68,14 @@ export const step = {
|
|
|
116
68
|
try {
|
|
117
69
|
text = ws.read(rel);
|
|
118
70
|
} catch (error) {
|
|
119
|
-
// *An empty set is two questions.* A file that could not be read has not answered
|
|
120
|
-
// anything, and reporting `not owed` here is "nothing looked" recorded as "nothing
|
|
121
|
-
// wrong" — in a step whose entire subject is a failure that is already silent.
|
|
122
71
|
return { owed: null, because: `${rel} could not be read — ${error.code ?? error.message}, so whether it carries a stale bundle path is unknown` };
|
|
123
72
|
}
|
|
124
|
-
if (!
|
|
73
|
+
if (!isMarkedScript(rel, text)) continue;
|
|
125
74
|
const found = marked(text);
|
|
126
75
|
if (!found.ok) {
|
|
127
76
|
return { owed: null, because: `${rel} line ${found.line} carries the marker in a shape this step does not recognise` };
|
|
128
77
|
}
|
|
129
|
-
if (found.hits.some((h) => h.
|
|
78
|
+
if (found.hits.some((h) => h.entry !== want)) any = true;
|
|
130
79
|
}
|
|
131
80
|
return any
|
|
132
81
|
? { owed: true, because: `a marked line names a bundle other than ${want}` }
|
|
@@ -144,10 +93,7 @@ export const step = {
|
|
|
144
93
|
} catch (error) {
|
|
145
94
|
return { ok: false, reason: `${rel} could not be read — ${error.code ?? error.message}` };
|
|
146
95
|
}
|
|
147
|
-
|
|
148
|
-
// scope that differed between asking and doing is `0020`'s class, and it would show up
|
|
149
|
-
// as a step reporting itself owed and then planning no edit.
|
|
150
|
-
if (!text.includes(MARKER) || !couldCarryTheRail(rel, text)) continue;
|
|
96
|
+
if (!isMarkedScript(rel, text)) continue;
|
|
151
97
|
const found = marked(text);
|
|
152
98
|
if (!found.ok) {
|
|
153
99
|
return {
|
|
@@ -158,18 +104,11 @@ export const step = {
|
|
|
158
104
|
`cannot read rather than guessing: ${found.text.trim()}`,
|
|
159
105
|
};
|
|
160
106
|
}
|
|
161
|
-
if (!found.hits.some((h) => h.
|
|
107
|
+
if (!found.hits.some((h) => h.entry !== want)) continue;
|
|
162
108
|
|
|
163
|
-
// Rewritten by SPLICING the one recognised literal, never by a replace over the line: a
|
|
164
|
-
// line may legitimately carry other quoted text, and a blanket substitution would edit it.
|
|
165
109
|
const lines = [...found.lines];
|
|
166
110
|
for (const hit of found.hits) {
|
|
167
|
-
// A
|
|
168
|
-
// `` $` ``, `$'` and `$$` in the REPLACEMENT as patterns, so a checkout under a
|
|
169
|
-
// directory whose name contains one of them spliced the old path back in with broken
|
|
170
|
-
// quoting — producing a corrupted rail that `doctor` then grades GREEN, because
|
|
171
|
-
// `doctor` never runs shell. A function replacer is inserted verbatim.
|
|
172
|
-
// Found at the pre-commit checkpoint, by building the path rather than reading the line.
|
|
111
|
+
// A function replacer: a string one reads `$&`, `` $` ``, `$'` and `$$` in the path as patterns.
|
|
173
112
|
lines[hit.n] = lines[hit.n].replace(hit.whole, () => JSON.stringify(want));
|
|
174
113
|
}
|
|
175
114
|
edits.push({ file: rel, next: lines.join("\n") });
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// A form step — the handoff index is printed on demand, and not kept.
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { handoffIndexIgnore, notYetForm, withIgnoreLines } from "../../cli/form.mjs";
|
|
6
|
+
|
|
7
|
+
const posix = (p) => p.split(path.sep).join("/");
|
|
8
|
+
|
|
9
|
+
function located(ws) {
|
|
10
|
+
const declared = ws.manifest?.handoffs?.index?.path;
|
|
11
|
+
if (typeof declared !== "string") return { skip: "this workspace declares no handoff index" };
|
|
12
|
+
if (!ws.repository) return { skip: "this workspace declares no tree, so no repository's git keeps a copy" };
|
|
13
|
+
const rel = path.relative(ws.repository.dir, path.resolve(ws.dir, declared));
|
|
14
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) return { skip: `the index at \`${declared}\` lies outside the tree` };
|
|
15
|
+
const git = ws.repository.git;
|
|
16
|
+
if (git === null) return { skip: "no git work tree answers here, so no copy is committed to stop keeping" };
|
|
17
|
+
return { rel: posix(rel), workspace: posix(path.relative(ws.repository.dir, ws.dir)) || ".", git };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** `--no-index` asks the ignore rules alone, so a tracked path answers too. */
|
|
21
|
+
function ignoredBy(at) {
|
|
22
|
+
const out = at.git("check-ignore", "-q", "--no-index", "--", at.rel);
|
|
23
|
+
if (out.status === 0) return { ignored: true };
|
|
24
|
+
if (out.status === 1) return { ignored: false };
|
|
25
|
+
return { ignored: null, why: `git check-ignore could not answer for ${at.rel} — ${out.stderr.trim() || `exit ${out.status}`}` };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const step = {
|
|
29
|
+
id: "0003-handoff-index-not-kept",
|
|
30
|
+
kind: "form",
|
|
31
|
+
from: null,
|
|
32
|
+
to: null,
|
|
33
|
+
title: "the handoff index is printed on demand, and not kept",
|
|
34
|
+
why:
|
|
35
|
+
"A committed handoff index conflicts on every merge that adds a handoff, and carries nothing the " +
|
|
36
|
+
"series does not: `portulan index --handoffs` prints it. The copy is deleted, so commit the deletion, " +
|
|
37
|
+
"and its path is git-ignored so none is committed again.",
|
|
38
|
+
|
|
39
|
+
owed(ws, ctx) {
|
|
40
|
+
const behind = notYetForm(ws, ctx);
|
|
41
|
+
if (behind) return { owed: false, because: behind };
|
|
42
|
+
const at = located(ws);
|
|
43
|
+
if (at.skip) return { owed: false, because: at.skip };
|
|
44
|
+
const rule = ignoredBy(at);
|
|
45
|
+
if (rule.ignored === null) return { owed: null, because: rule.why };
|
|
46
|
+
const kept = ws.repository.read(at.rel) !== null;
|
|
47
|
+
if (!rule.ignored) return { owed: true, because: `\`${at.rel}\` is not git-ignored${kept ? ", and a copy is kept" : ""}` };
|
|
48
|
+
const tracked = at.git("ls-files", "--error-unmatch", "--", at.rel).status === 0;
|
|
49
|
+
if (kept && tracked) return { owed: true, because: `\`${at.rel}\` is git-ignored and still tracked, so the committed copy conflicts on every merge that adds a handoff` };
|
|
50
|
+
return { owed: false, because: `\`${at.rel}\` is git-ignored${kept ? ", and the copy on disk is this machine's own" : ", and no copy is kept"}` };
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
plan(ws) {
|
|
54
|
+
const at = located(ws);
|
|
55
|
+
if (at.skip) return { ok: true, edits: [] };
|
|
56
|
+
const rule = ignoredBy(at);
|
|
57
|
+
if (rule.ignored === null) return { ok: false, reason: rule.why };
|
|
58
|
+
const edits = [];
|
|
59
|
+
if (ws.repository.read(at.rel) !== null) edits.push({ root: "tree", file: at.rel, next: null });
|
|
60
|
+
if (!rule.ignored) {
|
|
61
|
+
const before = ws.repository.read(".gitignore");
|
|
62
|
+
edits.push({ root: "tree", file: ".gitignore", next: withIgnoreLines(before, handoffIndexIgnore(at.rel, at.workspace)) });
|
|
63
|
+
}
|
|
64
|
+
return { ok: true, edits };
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// A form step — a changelog entry is a fragment under `changes/`.
|
|
2
|
+
|
|
3
|
+
import { CHANGELOG, CHANGES_DIR, CHANGES_README, changesReadme, notYetForm, unreleasedCount, unreleasedFragments, unreleasedRewrites } from "../../cli/form.mjs";
|
|
4
|
+
|
|
5
|
+
function rewritten(changelog) {
|
|
6
|
+
const lines = unreleasedRewrites(changelog);
|
|
7
|
+
if (lines.length === 0) return "";
|
|
8
|
+
if (lines.length === 1) return ` (the one at line ${lines[0]} does not open \`- \`, which its fragment does, as the release cut accepts no other opening)`;
|
|
9
|
+
return ` (those at lines ${lines.slice(0, -1).join(", ")} and ${lines.at(-1)} do not open \`- \`, which their fragments do, as the release cut accepts no other opening)`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const step = {
|
|
13
|
+
id: "0004-changelog-fragments",
|
|
14
|
+
kind: "form",
|
|
15
|
+
from: null,
|
|
16
|
+
to: null,
|
|
17
|
+
title: "a changelog entry is a fragment under `changes/`",
|
|
18
|
+
why:
|
|
19
|
+
"Two open changes that each add a bullet under Unreleased conflict on merge; a file per entry never " +
|
|
20
|
+
"does. Each entry moves to `changes/<nn>-<slug>.<section>.md`, proved to print back as the changelog " +
|
|
21
|
+
"held it but for an entry that does not open `- `, whose fragment does and which the step names by its " +
|
|
22
|
+
"line, and `portulan index --changes changes` prints them as the cut pastes them.",
|
|
23
|
+
|
|
24
|
+
owed(ws, ctx) {
|
|
25
|
+
const behind = notYetForm(ws, ctx);
|
|
26
|
+
if (behind) return { owed: false, because: behind };
|
|
27
|
+
if (!ws.repository) return { owed: false, because: "this workspace declares no tree, so no changelog sits beside it" };
|
|
28
|
+
const changelog = ws.repository.read(CHANGELOG);
|
|
29
|
+
const readme = ws.repository.read(CHANGES_README) !== null;
|
|
30
|
+
if (changelog === null) {
|
|
31
|
+
return { owed: false, because: readme ? `${CHANGES_README} is here, and no ${CHANGELOG} yet` : `no ${CHANGELOG} here for fragments to feed` };
|
|
32
|
+
}
|
|
33
|
+
const entries = unreleasedCount(changelog) ?? 0;
|
|
34
|
+
const owed = [];
|
|
35
|
+
if (!readme) owed.push(`no ${CHANGES_README}`);
|
|
36
|
+
if (entries) owed.push(`${entries} entr${entries === 1 ? "y" : "ies"} under ${CHANGELOG}'s Unreleased${rewritten(changelog)}`);
|
|
37
|
+
if (owed.length === 0) return { owed: false, because: `the changelog's entries are fragments in ${CHANGES_DIR}/` };
|
|
38
|
+
return { owed: true, because: owed.join(", and ") };
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
plan(ws) {
|
|
42
|
+
const edits = [];
|
|
43
|
+
if (ws.repository.read(CHANGES_README) === null) edits.push({ root: "tree", file: CHANGES_README, next: changesReadme() });
|
|
44
|
+
const changelog = ws.repository.read(CHANGELOG);
|
|
45
|
+
const moved = changelog === null ? null : unreleasedFragments(changelog, new Set(ws.repository.names(CHANGES_DIR)));
|
|
46
|
+
if (moved?.refused) return { ok: false, reason: moved.refused };
|
|
47
|
+
if (moved && moved.fragments.length) {
|
|
48
|
+
for (const fragment of moved.fragments) edits.push({ root: "tree", file: `${CHANGES_DIR}/${fragment.name}`, next: fragment.text });
|
|
49
|
+
edits.push({ root: "tree", file: CHANGELOG, next: moved.next });
|
|
50
|
+
}
|
|
51
|
+
return { ok: true, edits };
|
|
52
|
+
},
|
|
53
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// A form step — a change's record is its commit message, and the Session log is retired to a pointer.
|
|
2
|
+
|
|
3
|
+
import { notYetForm, retireSessionLogs, sessionLogPointer, sessionLogSections } from "../../cli/form.mjs";
|
|
4
|
+
|
|
5
|
+
function logsIn(ws) {
|
|
6
|
+
if (!ws.repository) return { skip: "this workspace declares no tree, so no repository holds a Session log" };
|
|
7
|
+
const git = ws.repository.git;
|
|
8
|
+
if (git === null) return { skip: "no git work tree answers here, and a retired log points at the commit that holds its entries" };
|
|
9
|
+
const listed = git("ls-files", "-z", "--", "*.md");
|
|
10
|
+
if (listed.status !== 0) return { unknown: `git ls-files could not list the tracked Markdown — ${listed.stderr.trim() || `exit ${listed.status}`}` };
|
|
11
|
+
const files = [];
|
|
12
|
+
for (const rel of listed.stdout.split("\0").filter(Boolean)) {
|
|
13
|
+
let text;
|
|
14
|
+
try {
|
|
15
|
+
text = ws.repository.read(rel);
|
|
16
|
+
} catch {
|
|
17
|
+
// A link, or a path the disk cannot give: a link's target is read in its own right where git tracks it.
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
if (text === null || !text.includes("Session log")) continue;
|
|
21
|
+
if (sessionLogSections(text).some((s) => s.entries)) files.push({ rel, text });
|
|
22
|
+
}
|
|
23
|
+
return { git, files };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const step = {
|
|
27
|
+
id: "0005-session-log-retired",
|
|
28
|
+
kind: "form",
|
|
29
|
+
from: null,
|
|
30
|
+
to: null,
|
|
31
|
+
title: "the Session log is retired to a pointer at the commit that holds it",
|
|
32
|
+
why:
|
|
33
|
+
"A log every change appends to conflicts on every merge and repeats what the commit messages say. " +
|
|
34
|
+
"Each `Session log` section with entries keeps its heading and two lines: the date it retired, and " +
|
|
35
|
+
"`git show <sha>:<file>`, the last commit holding its entries.",
|
|
36
|
+
|
|
37
|
+
owed(ws, ctx) {
|
|
38
|
+
const behind = notYetForm(ws, ctx);
|
|
39
|
+
if (behind) return { owed: false, because: behind };
|
|
40
|
+
const found = logsIn(ws);
|
|
41
|
+
if (found.skip) return { owed: false, because: found.skip };
|
|
42
|
+
if (found.unknown) return { owed: null, because: found.unknown };
|
|
43
|
+
if (found.files.length === 0) return { owed: false, because: "no tracked Markdown holds a Session log with entries" };
|
|
44
|
+
return { owed: true, because: `a Session log with entries in ${found.files.map((f) => f.rel).join(", ")}` };
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
plan(ws, ctx) {
|
|
48
|
+
const found = logsIn(ws);
|
|
49
|
+
if (found.skip) return { ok: true, edits: [] };
|
|
50
|
+
if (found.unknown) return { ok: false, reason: found.unknown };
|
|
51
|
+
const { git } = found;
|
|
52
|
+
// `git show <sha>:<path>` reads a path from the repository's top, and the tree may sit below it.
|
|
53
|
+
const prefix = git("rev-parse", "--show-prefix");
|
|
54
|
+
if (prefix.status !== 0) return { ok: false, reason: `git rev-parse could not name the tree's place in its repository — ${prefix.stderr.trim()}` };
|
|
55
|
+
const edits = [];
|
|
56
|
+
for (const { rel, text } of found.files) {
|
|
57
|
+
const status = git("status", "--porcelain", "--", rel);
|
|
58
|
+
if (status.status !== 0) return { ok: false, reason: `git status could not answer for ${rel} — ${status.stderr.trim() || `exit ${status.status}`}` };
|
|
59
|
+
if (status.stdout.trim() !== "") {
|
|
60
|
+
return { ok: false, reason: `${rel} has changes not committed, and its retired log would point at a commit that lacks them — commit or stash them, then upgrade` };
|
|
61
|
+
}
|
|
62
|
+
const sha = git("log", "-1", "--format=%h", "--", rel);
|
|
63
|
+
if (sha.status !== 0 || sha.stdout.trim() === "") return { ok: false, reason: `git names no commit holding ${rel}, so its retired log would point at nothing` };
|
|
64
|
+
const pointer = sessionLogPointer({ date: ctx.today, sha: sha.stdout.trim(), file: `${prefix.stdout.trim()}${rel}` });
|
|
65
|
+
edits.push({ root: "tree", file: rel, next: retireSessionLogs(text, pointer) });
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, edits };
|
|
68
|
+
},
|
|
69
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// A form step — the boot is a card, drafted from the workspace's own slots.
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { claudeRulesUnignore, draftCard, notYetForm, withIgnoreLines } from "../../cli/form.mjs";
|
|
6
|
+
|
|
7
|
+
/** The Workspace Definition version `slots.context` arrived in, which a manifest declaring it needs. */
|
|
8
|
+
const CONTEXT_SPEC = "2.10";
|
|
9
|
+
|
|
10
|
+
const posix = (p) => p.split(path.sep).join("/");
|
|
11
|
+
|
|
12
|
+
function atLeastContextSpec(declared) {
|
|
13
|
+
const [major, minor] = String(declared).split(".").map(Number);
|
|
14
|
+
const [needMajor, needMinor] = CONTEXT_SPEC.split(".").map(Number);
|
|
15
|
+
return major > needMajor || (major === needMajor && minor >= needMinor) ? declared : CONTEXT_SPEC;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const step = {
|
|
19
|
+
id: "0006-boot-card",
|
|
20
|
+
kind: "form",
|
|
21
|
+
from: null,
|
|
22
|
+
to: null,
|
|
23
|
+
title: "the boot is a card, drafted from the workspace's own slots",
|
|
24
|
+
why:
|
|
25
|
+
"A boot through the slots reads the skill's steps and every slot file in full; a card carries the " +
|
|
26
|
+
"same, compiled into the rules the host loads, and a session reads no slot to boot. `context/boot.md` " +
|
|
27
|
+
"is drafted, `slots.context` declared at 2.10, and `0007` compiles it. Delete the card to boot as before.",
|
|
28
|
+
|
|
29
|
+
owed(ws, ctx) {
|
|
30
|
+
const behind = notYetForm(ws, ctx);
|
|
31
|
+
if (behind) return { owed: false, because: behind };
|
|
32
|
+
const kind = ws.manifest?.kind;
|
|
33
|
+
if (kind !== "repository") return { owed: false, because: `a \`${kind}\` workspace has no repository of its own whose host would load a card` };
|
|
34
|
+
if (!ws.repository) return { owed: false, because: "this workspace declares no tree for a card to load in" };
|
|
35
|
+
if (typeof ws.manifest?.slots?.context === "string") {
|
|
36
|
+
return { owed: false, because: "`slots.context` is declared: a `boot` unit in it is the card, and a slot without one boots through the slots" };
|
|
37
|
+
}
|
|
38
|
+
return { owed: true, because: "`slots.context` is undeclared, so a session boots by reading every slot; the card is drafted from them, and `0007` then compiles it" };
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
plan(ws) {
|
|
42
|
+
if (ws.list().some((rel) => rel === "context" || rel.startsWith("context/"))) {
|
|
43
|
+
return { ok: false, reason: "`context/` is already in the workspace and is no declared slot — rename it, or declare `slots.context` yourself, then upgrade" };
|
|
44
|
+
}
|
|
45
|
+
const manifest = {
|
|
46
|
+
...ws.manifest,
|
|
47
|
+
portulan: { ...ws.manifest.portulan, spec: atLeastContextSpec(ws.manifest.portulan?.spec) },
|
|
48
|
+
slots: { ...ws.manifest.slots, context: "context/" },
|
|
49
|
+
};
|
|
50
|
+
const tree = ws.repository.dir;
|
|
51
|
+
const workspace = posix(path.relative(tree, ws.dir)) || ".";
|
|
52
|
+
const inTree = (rel) => {
|
|
53
|
+
const at = path.relative(tree, path.resolve(ws.dir, rel));
|
|
54
|
+
return at !== "" && !at.startsWith("..") && !path.isAbsolute(at);
|
|
55
|
+
};
|
|
56
|
+
const read = (rel) => {
|
|
57
|
+
try {
|
|
58
|
+
return ws.read(rel);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const repos = typeof manifest.slots.repos === "string" ? manifest.slots.repos.replace(/\/?$/, "/") : null;
|
|
64
|
+
const repoCards = repos === null
|
|
65
|
+
? []
|
|
66
|
+
: ws.list().filter((rel) => rel.startsWith(repos) && rel.endsWith(".md") && !rel.slice(repos.length).includes("/") && path.posix.basename(rel) !== "README.md").map((rel) => path.posix.basename(rel, ".md"));
|
|
67
|
+
|
|
68
|
+
const edits = [
|
|
69
|
+
{ file: "workspace.json", next: `${JSON.stringify(manifest, null, 2)}\n` },
|
|
70
|
+
{ file: "context/boot.md", next: draftCard(manifest, read, { workspace, inTree, repoCards }) },
|
|
71
|
+
];
|
|
72
|
+
// A `.gitignore` hiding `.claude/` would keep the compiled card out of review and every fresh checkout.
|
|
73
|
+
const { lines } = claudeRulesUnignore(tree, ws.repository.git);
|
|
74
|
+
if (lines.length) edits.push({ root: "tree", file: ".gitignore", next: withIgnoreLines(ws.repository.read(".gitignore"), lines) });
|
|
75
|
+
return { ok: true, edits };
|
|
76
|
+
},
|
|
77
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// A form step — the guidance the workspace declares is compiled into what the host loads.
|
|
2
|
+
|
|
3
|
+
import { guidanceEdits } from "../../cli/compile.mjs";
|
|
4
|
+
import { notYetForm } from "../../cli/form.mjs";
|
|
5
|
+
|
|
6
|
+
export const step = {
|
|
7
|
+
id: "0007-guidance-compiled",
|
|
8
|
+
kind: "form",
|
|
9
|
+
from: null,
|
|
10
|
+
to: null,
|
|
11
|
+
title: "the declared guidance is compiled into the rules the host loads",
|
|
12
|
+
why:
|
|
13
|
+
"A unit in `slots.context` reaches a session only as the rule `compile` writes for it. The rules are " +
|
|
14
|
+
"written through compile's own planner, as `portulan compile` writes them, and never " +
|
|
15
|
+
"`.claude/settings.json`.",
|
|
16
|
+
|
|
17
|
+
owed(ws, ctx) {
|
|
18
|
+
const behind = notYetForm(ws, ctx);
|
|
19
|
+
if (behind) return { owed: false, because: behind };
|
|
20
|
+
if (!ws.repository) return { owed: false, because: "this workspace declares no tree for compiled guidance to load in" };
|
|
21
|
+
if (typeof ws.manifest?.slots?.context !== "string") return { owed: false, because: "`slots.context` is undeclared, so there is no guidance to compile" };
|
|
22
|
+
const planned = guidanceEdits(ws.dir);
|
|
23
|
+
const left = planned.left.length ? `; ${planned.left.join(", ")} ${planned.left.length === 1 ? "is" : "are"} not compile's, and left as \`compile\` leaves ${planned.left.length === 1 ? "it" : "them"}` : "";
|
|
24
|
+
if (planned.edits.length === 0) return { owed: false, because: `the compiled guidance is what \`compile\` would write${left}` };
|
|
25
|
+
return { owed: true, because: `${planned.edits.map((e) => e.file).join(", ")} ${planned.edits.length === 1 ? "differs" : "differ"} from what \`compile\` would write${left}` };
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
plan(ws) {
|
|
29
|
+
return { ok: true, edits: guidanceEdits(ws.dir).edits.map((edit) => ({ root: "tree", file: edit.file, next: edit.next })) };
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// A form step — the card carries the engine's rules on reading and the cache.
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import { BOOT_CARD_UNIT } from "../../cli/compile.mjs";
|
|
6
|
+
import { carriesReading, HEAD_BEFORE_READING_SHOWN, notYetForm, READING_LINE, withReading } from "../../cli/form.mjs";
|
|
7
|
+
|
|
8
|
+
function cardOf(ws) {
|
|
9
|
+
const context = ws.manifest?.slots?.context;
|
|
10
|
+
if (typeof context !== "string") return { answer: { owed: false, because: "`slots.context` is undeclared, so there is no card; `0006` drafts one with the section" } };
|
|
11
|
+
const rel = path.posix.join(context, `${BOOT_CARD_UNIT}.md`);
|
|
12
|
+
try {
|
|
13
|
+
return { rel, card: ws.read(rel) };
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error?.code === "ENOENT") return { answer: { owed: false, because: "`slots.context` holds no `boot` unit, so there is no card to carry the section" } };
|
|
16
|
+
return { answer: { owed: null, because: `${rel} could not be read: ${error.message}` } };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const unplaced = (rel) =>
|
|
21
|
+
`${rel} does not carry the engine's rules on reading and the cache, and its head is not one \`init\` drafted ` +
|
|
22
|
+
`before 2026-09-24, ${HEAD_BEFORE_READING_SHOWN}, so this step does not guess where they go: add a section ` +
|
|
23
|
+
`holding the line \`${READING_LINE}\` to the card, then upgrade again`;
|
|
24
|
+
|
|
25
|
+
export const step = {
|
|
26
|
+
id: "0008-card-reading",
|
|
27
|
+
kind: "form",
|
|
28
|
+
from: null,
|
|
29
|
+
to: null,
|
|
30
|
+
title: "the card carries the engine's rules on reading and the cache",
|
|
31
|
+
why:
|
|
32
|
+
"The card `init` drafts opens with the rules of Portulan's `core/operating/context.md` on what a " +
|
|
33
|
+
"session's reads, output and pauses cost on every request after them, written out by `compile`. A card " +
|
|
34
|
+
"drafted before them gets the section and `0007` compiles it; one under any other head is reported with " +
|
|
35
|
+
"the line to add by hand, and the rest of the chain runs.",
|
|
36
|
+
|
|
37
|
+
owed(ws, ctx) {
|
|
38
|
+
const behind = notYetForm(ws, ctx);
|
|
39
|
+
if (behind) return { owed: false, because: behind };
|
|
40
|
+
if (ws.manifest?.kind !== "repository") return { owed: false, because: `a \`${ws.manifest?.kind}\` workspace has no repository of its own whose host would load a card` };
|
|
41
|
+
const { rel, card, answer } = cardOf(ws);
|
|
42
|
+
if (answer) return answer;
|
|
43
|
+
if (carriesReading(card)) return { owed: false, because: `${rel} carries the engine's rules on reading and the cache` };
|
|
44
|
+
if (withReading(card) === null) return { owed: true, hand: true, because: unplaced(rel) };
|
|
45
|
+
return { owed: true, because: `${rel} was drafted before the card carried the engine's rules on reading and the cache: they go under its head, and \`0007\` then compiles it` };
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
plan(ws) {
|
|
49
|
+
const { rel, card, answer } = cardOf(ws);
|
|
50
|
+
if (answer) return { ok: false, reason: answer.because };
|
|
51
|
+
const next = withReading(card);
|
|
52
|
+
if (next === null) return { ok: false, reason: unplaced(rel) };
|
|
53
|
+
return { ok: true, edits: [{ file: rel, next }] };
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// A form step — each section a team marks in its own instruction file moves to an on-read unit.
|
|
2
|
+
|
|
3
|
+
import { ESTIMATED_BYTES_PER_TOKEN, alwaysTier, declaredContext, tokensOf } from "../../cli/context.mjs";
|
|
4
|
+
import { notYetForm } from "../../cli/form.mjs";
|
|
5
|
+
import { INSTRUCTION_FILES, contextDir, grouped, instructionReader, linkRefusal, marksOf, planSplit, splitLines } from "../../cli/instructions.mjs";
|
|
6
|
+
|
|
7
|
+
function splitOf(ws) {
|
|
8
|
+
const tree = ws.repository.dir;
|
|
9
|
+
const context = contextDir(tree, ws.dir, ws.manifest);
|
|
10
|
+
const taken = context === null ? [] : ws.repository.names(context).filter((name) => name.endsWith(".md")).map((name) => name.slice(0, -3));
|
|
11
|
+
return planSplit({ tree, context, taken, read: instructionReader(tree, ws.repository.read) });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const step = {
|
|
15
|
+
id: "0009-instruction-sections-on-read",
|
|
16
|
+
kind: "form",
|
|
17
|
+
from: null,
|
|
18
|
+
to: null,
|
|
19
|
+
title: "each section a team marks in its instruction file moves to an on-read unit",
|
|
20
|
+
why:
|
|
21
|
+
"`CLAUDE.md` and `.claude/CLAUDE.md` load whole into every context. A section marked " +
|
|
22
|
+
"`<!-- portulan: on-read -->` moves, byte for byte, to an on-read unit of `slots.context`, proved to " +
|
|
23
|
+
"reassemble, and every context loads one index line for it. " +
|
|
24
|
+
"`node <plugin root>/cli/instructions.mjs --workspace <dir> --join --write` puts it back.",
|
|
25
|
+
|
|
26
|
+
owed(ws, ctx) {
|
|
27
|
+
const behind = notYetForm(ws, ctx);
|
|
28
|
+
if (behind) return { owed: false, because: behind };
|
|
29
|
+
if (!ws.repository) return { owed: false, because: "this workspace declares no tree, so there is no instruction file of its own" };
|
|
30
|
+
const read = instructionReader(ws.repository.dir, ws.repository.read);
|
|
31
|
+
const marked = INSTRUCTION_FILES.filter((rel) => marksOf(read(rel) ?? "").marks.length > 0);
|
|
32
|
+
if (marked.length === 0) return { owed: false, because: "no section of CLAUDE.md or .claude/CLAUDE.md is marked `<!-- portulan: on-read -->`" };
|
|
33
|
+
if (typeof ws.manifest?.slots?.context !== "string") {
|
|
34
|
+
return { owed: false, because: `${marked.join(" and ")} marks a section, and \`slots.context\` is undeclared: \`0006\` declares it, and this step is asked again then` };
|
|
35
|
+
}
|
|
36
|
+
const linked = marked.map((rel) => linkRefusal(ws.repository.dir, rel)).filter((refusal) => refusal !== null);
|
|
37
|
+
if (linked.length) return { owed: true, hand: true, because: `a marked section cannot move, so none is moved — ${linked.join("; ")}, or take the marks out, then upgrade again` };
|
|
38
|
+
const split = splitOf(ws);
|
|
39
|
+
if (split.refusals.length) return { owed: true, because: `a marked section cannot move — ${split.refusals.join("; ")}` };
|
|
40
|
+
const ratio = declaredContext(ws.manifest).ratio ?? ESTIMATED_BYTES_PER_TOKEN;
|
|
41
|
+
const before = alwaysTier(ws.repository.dir).entries.reduce((n, e) => n + e.bytes, 0);
|
|
42
|
+
const after = before - split.files.reduce((n, f) => n + f.removed, 0) + split.index;
|
|
43
|
+
return {
|
|
44
|
+
owed: true,
|
|
45
|
+
because: `${splitLines(split).join("; ")}; the always tier goes from ~${grouped(tokensOf(before, ratio))} to ~${grouped(tokensOf(after, ratio))} tokens`,
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
plan(ws) {
|
|
50
|
+
const split = splitOf(ws);
|
|
51
|
+
if (split.refusals.length) return { ok: false, reason: `a marked section cannot move, so none is moved — ${split.refusals.join("; ")}` };
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
edits: [
|
|
55
|
+
...split.files.map((f) => ({ root: "tree", file: f.rel, next: f.after })),
|
|
56
|
+
...split.units.map((u) => ({ root: "tree", file: u.source, next: u.text })),
|
|
57
|
+
],
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|