@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,64 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: delegate-local
|
|
3
|
+
description: Offload a verify-gated one-file edit, a file digest or a draft to a local model. Use only where the workspace composes tools/local-llm and a server answers on loopback.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skill — Delegate Local
|
|
7
|
+
|
|
8
|
+
> Every turn in a frontier session re-reads its whole context (`m_r × (F·T + g·T²/2)`, proposal
|
|
9
|
+
> [`0038`](../../../../../.portulan/proposals/0038-what-a-change-spends-is-measured.md)). This skill moves a
|
|
10
|
+
> bounded, high-turn or read-heavy sub-task off that context and onto a local model on loopback, through
|
|
11
|
+
> [`runner/local-llm.mjs`](../../runner/local-llm.mjs) and under a verify command.
|
|
12
|
+
|
|
13
|
+
## Before you start
|
|
14
|
+
|
|
15
|
+
`<runner>` below is `runner/local-llm.mjs` in this pack's directory, two levels above this file:
|
|
16
|
+
`<plugin root>/packs/tools/local-llm/runner/local-llm.mjs` where the plugin is the repository root, and
|
|
17
|
+
`<plugin root>/tools/local-llm/runner/local-llm.mjs` where it is the `packs/` payload.
|
|
18
|
+
|
|
19
|
+
The runner talks to an OpenAI-compatible server on loopback: LM Studio on `127.0.0.1:1234` by default,
|
|
20
|
+
or `--base-url` (or `PORTULAN_LOCAL_LLM_URL`) for another loopback port such as Ollama's `11434`. Run
|
|
21
|
+
`node <runner> status` first. Exit `0` lists the models the server holds; exit `1` is an endpoint
|
|
22
|
+
refused as off loopback; exit `2` is could-not-run — no server answered, it answered with an HTTP
|
|
23
|
+
error, or `--base-url` is not a URL. On anything but `0`, do the work in the parent session. Never pass
|
|
24
|
+
`--allow-remote` on your own authority: an endpoint off loopback is the `local-llm-off-loopback`
|
|
25
|
+
decision, proposed before it runs.
|
|
26
|
+
|
|
27
|
+
## When to use it
|
|
28
|
+
|
|
29
|
+
- **A verify-gated file transform:** a mechanical refactor, a history-comment cleanup, or a unit test
|
|
30
|
+
added, where a verify command can prove the edit green or have it rolled back.
|
|
31
|
+
- **An explicitly selected candidate research digest:** reading a long file or log so only a short digest enters the parent context.
|
|
32
|
+
- **An explicitly selected candidate librarian or handoff draft:** compressing a handoff under its byte cap, or drafting a `codify` or
|
|
33
|
+
`consolidate` candidate for human review.
|
|
34
|
+
|
|
35
|
+
Not for multi-file architectural planning, ambiguous requirements (`clarify`), or pre-merge verdicts
|
|
36
|
+
(`reviewer`).
|
|
37
|
+
|
|
38
|
+
## The pass
|
|
39
|
+
|
|
40
|
+
1. **Scope the input first.** Outline a file of 300 lines or more with
|
|
41
|
+
`node <plugin root>/cli/symbols.mjs <file>` and pass `--file <file> --lines <start>-<end>` (to
|
|
42
|
+
`transform`, `chat` and `summarize` alike) rather than the whole file. `cli/` ships with the
|
|
43
|
+
repository-root plugin and not in the `packs/` payload; there, find the span's line numbers with
|
|
44
|
+
`grep -n`. Prefill scales with prompt bytes: proposal 0039 measured prompts of about 400–470 tokens
|
|
45
|
+
prefilling in 0.85–1.88 s, and the `32 KB` default of `--max-input-bytes` (about 8k tokens) is a
|
|
46
|
+
ceiling, not a measured latency.
|
|
47
|
+
2. **Use an earned role.** Code transforms and strict syntax use `--role code`
|
|
48
|
+
(`qwen/qwen3.8-27b`) at `--effort low`, supported by the
|
|
49
|
+
[captured A/B](../../../../../evals/ab/local-worker.md)'s L5/H5 code cell. Shared `--role prose`
|
|
50
|
+
(`google/gemma-4-31b`) is disabled: codify scored L0/H3, despite handoff's L5/H5. Keep digests and
|
|
51
|
+
librarian drafts in the parent session. `summarize` defaults to prose and refuses; explicit roles,
|
|
52
|
+
model overrides and environment mismatches cannot bypass the guard. An operator can select a
|
|
53
|
+
candidate with `--model <id>` and no `--role`; that is deliberate evaluation, not earned routing.
|
|
54
|
+
Screen a candidate with `node <runner> eval --model <id>`; it needs `cli/` beside `packs/` and exits
|
|
55
|
+
`2` without it. Passing a screen never enables a role.
|
|
56
|
+
3. **Gate every edit.** `node <runner> transform --file <path> --verify "<cmd>" --prompt "<instruction>"`,
|
|
57
|
+
where `--verify` is required. The runner applies the edit, runs `<cmd>`, retries with the end of its
|
|
58
|
+
output on exit `1`, and keeps the edit only on exit `0`; any other exit, and SIGINT, SIGTERM or
|
|
59
|
+
SIGHUP, restores the file byte for byte. It is a rollback, not an atomic write: any other signal that
|
|
60
|
+
ends the runner, SIGKILL and SIGQUIT among them, or a power loss between the edit and its verdict
|
|
61
|
+
leaves the edit in place.
|
|
62
|
+
4. **Read only the receipt back.** On exit `0`, take the one-line receipt and do not re-read the file
|
|
63
|
+
unless a later edit depends on its new line numbers. On exit `1` or `2`, escalate to the frontier
|
|
64
|
+
implementer rather than looping locally again.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# `runner-rails` — what it checks, and what it cannot
|
|
2
|
+
|
|
3
|
+
One recipe: `node ${PACK_ROOT}/runner/local-llm.mjs --verify-offline`, run from the adopting repository's root.
|
|
4
|
+
|
|
5
|
+
## The three exits
|
|
6
|
+
|
|
7
|
+
| Exit | Means |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `0` | all offline runner rails hold: non-loopback URLs are refused without `--allow-remote`, oversized inputs above `--max-input-bytes` are refused before network I/O, a verify command ending in any exit but `0` or `1` is could-not-run with the file restored, a green verify command keeps the edit with a one-line receipt, transform rollback restores original file bytes on a red verify command, and `dedup` refuses a non-loopback endpoint and a file over the ceiling before any request |
|
|
10
|
+
| `1` | at least one runner rail failed to fire (a forced-red check passed when it should have been refused, rollback left modified bytes behind, a green verify did not keep its edit with a one-line receipt, or `dedup` reached its request) |
|
|
11
|
+
| `2` | **could not run** — [`runner/local-llm.mjs`](../runner/local-llm.mjs) or the Node.js runtime could not be executed |
|
|
12
|
+
|
|
13
|
+
## Why `--verify-offline` makes no network or loopback call
|
|
14
|
+
|
|
15
|
+
A verify recipe in Portulan may never make a network call, and a local LLM server (`localhost:1234`) is
|
|
16
|
+
machine-specific state that is absent in CI. `--verify-offline` exercises the runner's guards against
|
|
17
|
+
in-process synthetic fixtures and three temporary-file transform drills (red, no verdict, green) and a `dedup` drill over two temporary files, proving the rails hold without
|
|
18
|
+
touching a socket. Live model health checks and role grading use `status` or `eval` outside recipes.
|
package/spec/README.md
CHANGED
|
@@ -114,7 +114,9 @@ carries, applied to the handoff series — `slots.handoffs` when `handoffs` is d
|
|
|
114
114
|
either counted as a handoff by the `record` check's correspondence or failed by it for carrying no
|
|
115
115
|
date; and, as of 2.6, that same pair a third time over the per-persona scope layer — `slots.personas`
|
|
116
116
|
when `personas` is declared, and `personas.index.path` resolving **outside** `slots.personas`, since
|
|
117
|
-
`index`'s orphan sweep would examine a file sited there as an undeclared persona location
|
|
117
|
+
`index`'s orphan sweep would examine a file sited there as an undeclared persona location; and, as of
|
|
118
|
+
2.11, `memory.store.budget.record_kilobytes` when `memory.store.budget.cutoff` is declared, since a
|
|
119
|
+
cutoff says which records that cap binds and alone configures nothing.
|
|
118
120
|
Everything else is optional.
|
|
119
121
|
|
|
120
122
|
_The 2.6 pair was enforced in `doctor` from the day the keys landed and was missing from this list until
|
|
@@ -139,7 +141,7 @@ a manifest in *another repository*, and no contract over a single document can s
|
|
|
139
141
|
join the list, and the reason is what keeps that list meaningful: every entry there is a dependency
|
|
140
142
|
between two keys **of one manifest**, which is the thing a reader of the schema alone would expect to find
|
|
141
143
|
and does not. A check that needs a second repository is not in that category, and folding it in would make
|
|
142
|
-
the count measure two different things at once. **The count stays at
|
|
144
|
+
the count measure two different things at once. **The count stays at ten**, and it counts what it always
|
|
143
145
|
counted.
|
|
144
146
|
|
|
145
147
|
[`slots.md`](slots.md) argues each one.
|
|
@@ -150,8 +152,8 @@ defect [#77](https://github.com/sleepy-panda-srl/portulan/issues/77) is filed ab
|
|
|
150
152
|
and adding to the list at 2.5 is exactly the edit that would have left it stale again — which is how
|
|
151
153
|
this one was noticed._
|
|
152
154
|
|
|
153
|
-
That the count went from one to three in a single MINOR, then to five, then to seven,
|
|
154
|
-
**
|
|
155
|
+
That the count went from one to three in a single MINOR, then to five, then to seven, then to nine, and
|
|
156
|
+
now stands at **ten**, is worth noticing rather than absorbing. Each is a genuine dependency between two keys and none
|
|
155
157
|
can be written in the declared subset, so the gap between *what the schema says* and *what a conforming
|
|
156
158
|
manifest must satisfy* is widening — and a constraint invisible to someone reading the schema alone is a
|
|
157
159
|
real cost, stated here rather than discovered. The subset earns its narrowness by being implementable
|
|
@@ -179,6 +181,38 @@ subset can say, and `doctor` returns at *the manifest must conform first* before
|
|
|
179
181
|
runs. It is a live hole for `cli/index.mjs` alone, which validates against no schema by design and
|
|
180
182
|
therefore refuses the string itself.)_
|
|
181
183
|
|
|
184
|
+
**2.9 adds nothing to that list either**: `context` requires `ratio`, and `ratio` requires its two
|
|
185
|
+
fields, in the schema itself, because `required` is in the subset. It adds **two** keys to the hand-check,
|
|
186
|
+
so it covers **nine**. `context.always.budget.tokens` is the fifth budget the subset can only type as
|
|
187
|
+
`number`. `context.ratio.bytes_per_token` is the first key held to something other than a positive
|
|
188
|
+
integer: 2.99 bytes per token is a real figure, so the check is *at least 1*, since a token covers at
|
|
189
|
+
least one byte and a smaller figure is tokens per byte entered inverted. For that key `minimum` alone
|
|
190
|
+
would close the gap.
|
|
191
|
+
|
|
192
|
+
**2.10 adds nothing to either list.** `slots.context` is a directory path, which the schema types in full,
|
|
193
|
+
and what a unit's frontmatter may say is `compile`'s to refuse, because the subset cannot see inside a
|
|
194
|
+
file. `doctor` checks the slot as it checks every directory slot, and gates it to 2.10.
|
|
195
|
+
|
|
196
|
+
**2.11 adds one to each list.** `memory.store.budget.cutoff` needs `record_kilobytes` beside it, the
|
|
197
|
+
tenth conditional requirement; and its `pattern` admits a day that does not exist, such as `2026-02-30`,
|
|
198
|
+
so `doctor` and `cli/index.mjs` refuse one by hand, and the hand-check covers **ten**.
|
|
199
|
+
`sessions` adds nothing to either: every field of it is a boolean or one of two strings, which the
|
|
200
|
+
schema types in full with `type` and `enum`, so `doctor` only gates the key to 2.11.
|
|
201
|
+
|
|
202
|
+
**2.12 adds five to the hand-check and nothing to the conditional requirements.** `required` holds both
|
|
203
|
+
halves of `spend.multipliers` and both lifetimes of its `write` in the schema itself, and the subset types
|
|
204
|
+
the four figures only as `number`, the horizon included, since it cannot say `integer`, so `doctor` holds
|
|
205
|
+
the read in (0, 1], each write at least 1 and the horizon to a positive integer by hand, and refuses a write
|
|
206
|
+
the read divides past the largest number, since the restart threshold divides one by the other; the
|
|
207
|
+
hand-check covers **fifteen**. For the two writes `minimum` alone would close the gap, as it would for the
|
|
208
|
+
ratio; the read is the first figure bounded above, so it would need `exclusiveMinimum` and `maximum`, and
|
|
209
|
+
the horizon the budgets' `minimum` and `integer`; no keyword in the subset relates two figures, so the
|
|
210
|
+
quotient stays by hand.
|
|
211
|
+
|
|
212
|
+
**2.13 adds nothing to either list.** `spend.restart` is one of two strings, which the subset types in full
|
|
213
|
+
with `type` and `enum`, so the key needs no hand-check, only the gate to 2.13, which `doctor` and `compile`
|
|
214
|
+
both hold.
|
|
215
|
+
|
|
182
216
|
_These figures are history rather than state: what 2.3 and 2.4 added cannot change, so they do not go
|
|
183
217
|
stale the way the removed count did. The one forward-looking sentence is the growth rate, and it is
|
|
184
218
|
dated by the version it names._
|
|
@@ -194,15 +228,87 @@ number governing both would make a bump in either mean a change in the other:
|
|
|
194
228
|
|
|
195
229
|
| Schema | Manifest key | Current | What it governs |
|
|
196
230
|
|---|---|---|---|
|
|
197
|
-
| [`workspace.schema.json`](workspace.schema.json) | `portulan.spec` | **2.
|
|
231
|
+
| [`workspace.schema.json`](workspace.schema.json) | `portulan.spec` | **2.13** | the Workspace Definition — the manifest at a workspace root |
|
|
198
232
|
| [`pack.schema.json`](pack.schema.json) | `portulan.pack` | **1.0** | the Pack Definition — the manifest at a pack root. |
|
|
199
233
|
|
|
200
234
|
The rules below apply to each train independently. `portulan.spec` is `MAJOR.MINOR`, and the current
|
|
201
|
-
Workspace Definition version is **2.
|
|
235
|
+
Workspace Definition version is **2.13**. It did **not** move when the Pack Definition arrived, because
|
|
202
236
|
`workspace.schema.json` was byte-identical across that change: `packs` already existed as an array of
|
|
203
237
|
strings and was deliberately left that way, since tightening its items to the canonical `category/name`
|
|
204
238
|
form would be a constraint an existing manifest could newly fail, which is a MAJOR.
|
|
205
239
|
|
|
240
|
+
**2.13 is a MINOR on 2.8's terms: optional keys only.** `spend.restart` is the last piece of item 5 of
|
|
241
|
+
proposal `0038`'s order of work, the declared block its ruling 3 allows: `"advise"` is the restart advisory as
|
|
242
|
+
it was, and `"block"` also holds a turn's end once, at the first stop at or past the threshold that no block
|
|
243
|
+
provoked, with the advisory's line as the reason; [`slots.md`](slots.md) argues it. Nothing is removed,
|
|
244
|
+
renamed, tightened or defaulted: without the key, or with `"advise"`, the settings compile byte for byte as
|
|
245
|
+
before, so every 2.12 manifest is a valid 2.13 manifest unchanged. This repository's own workspace stays on
|
|
246
|
+
2.11 and declares no block: it declares no multipliers either, and at the general ones the line comes early
|
|
247
|
+
for a model whose cache reads cost less than a tenth, so a block would hold its sessions short of their real
|
|
248
|
+
line. Nothing writes 2.13 unasked: `init` asks nothing about the key and `upgrade` offers nothing, since
|
|
249
|
+
ruling 3 makes a block a workspace's own choice. `KNOWN_SPECS` in [`../cli/index.mjs`](../cli/index.mjs) and
|
|
250
|
+
[`../cli/librarian.mjs`](../cli/librarian.mjs) gains `"2.13"` by addition, and `doctor` and `compile` both
|
|
251
|
+
refuse the key in a manifest that declares an earlier MINOR.
|
|
252
|
+
|
|
253
|
+
**2.12 is a MINOR on 2.8's terms: optional keys only.** `spend` is item 4 of proposal `0038`'s order of
|
|
254
|
+
work: the multipliers the restart threshold is computed at, which its ruling 2 has the manifest declare, and
|
|
255
|
+
the horizon the threshold is judged over, 20 requests under ruling 3 until the manifest declares another;
|
|
256
|
+
[`slots.md`](slots.md) argues it. Nothing is removed, renamed, tightened or defaulted: without `spend`,
|
|
257
|
+
[`../cli/ledger.mjs`](../cli/ledger.mjs) and the restart advisory price at the general multipliers as before
|
|
258
|
+
and say `undeclared`, and the settings compile byte for byte as before, so every 2.11 manifest is a valid
|
|
259
|
+
2.12 manifest unchanged. This repository's own workspace stays on 2.11 and declares no `spend`; it now
|
|
260
|
+
declares `sessions.headless.cache_lifetime` `"5m"`, a 2.11 key. `examples/` stays on 2.4. `init` writes 2.11
|
|
261
|
+
where a person takes the five-minute cache lifetime it now offers, since `sessions` is 2.11's key, and 2.10
|
|
262
|
+
otherwise. `KNOWN_SPECS` in [`../cli/index.mjs`](../cli/index.mjs) and
|
|
263
|
+
[`../cli/librarian.mjs`](../cli/librarian.mjs) gains `"2.12"` by addition, and `doctor` gates the key to
|
|
264
|
+
2.12. `0038`'s third key, the declared Stop-gate block, takes the next free MINOR, 2.13, in its own change,
|
|
265
|
+
and `0034`'s keys take the next free MINOR when they are drafted.
|
|
266
|
+
|
|
267
|
+
**2.11 is a MINOR on 2.8's terms: optional keys only.** `memory.store.budget.cutoff`
|
|
268
|
+
makes the per-record cap forward-only, in proposal `0037`'s shape for a handoff, and [`slots.md`](slots.md)
|
|
269
|
+
argues it. `sessions` is the machine half of [`../core/operating/sessions.md`](../core/operating/sessions.md),
|
|
270
|
+
from `0038`'s rules 3 and 4: the host switches that change what a fresh session writes into the prompt cache,
|
|
271
|
+
two of which `compile` writes into the settings it already generates and one of which only a runner can apply;
|
|
272
|
+
[`slots.md`](slots.md) argues it too. Nothing is removed, renamed, tightened or defaulted: without the cutoff,
|
|
273
|
+
`record_kilobytes` binds every record as it did, and without `sessions` the settings compile byte for byte as
|
|
274
|
+
before, so every 2.10 manifest is a valid 2.11 manifest unchanged. This repository's own workspace moves from
|
|
275
|
+
2.10 to 2.11 to declare the cutoff, with the cap lowered from 8 KB to 2 and the cutoff 2026-09-24, and declares
|
|
276
|
+
no `sessions`; `examples/` stays on 2.4. `KNOWN_SPECS` in [`../cli/index.mjs`](../cli/index.mjs) and
|
|
277
|
+
[`../cli/librarian.mjs`](../cli/librarian.mjs) gains `"2.11"` by addition, `doctor` gates both keys to 2.11,
|
|
278
|
+
and the four writers stay at `2.7`, since nothing they scaffold declares either. `0034`'s key and the rest of
|
|
279
|
+
`0038`'s still take the next free MINOR when they are drafted.
|
|
280
|
+
|
|
281
|
+
**2.10 is a MINOR on 2.9's terms: one optional slot and nothing else.** `slots.context` is the content
|
|
282
|
+
half of [`../core/operating/context.md`](../core/operating/context.md), from proposal `0036`'s compile
|
|
283
|
+
targets: a directory of guidance, each file declaring in its frontmatter the load tier it belongs in, which
|
|
284
|
+
`compile` emits in each host's own form. Nothing is removed, renamed, tightened or defaulted, so every 2.9
|
|
285
|
+
manifest is a valid 2.10 manifest unchanged, and [`slots.md`](slots.md) argues the slot. It is the first
|
|
286
|
+
MINOR with two digits, and every reader compares the MINOR as a number or matches the string whole, so
|
|
287
|
+
`2.10` sorts after `2.9`. `0034`'s and `0038`'s keys still take the next free MINOR when they are drafted.
|
|
288
|
+
|
|
289
|
+
**Measured across the bump:** `node cli/doctor.mjs .portulan examples` is GREEN, with `.portulan` on 2.8
|
|
290
|
+
and `examples/` on 2.4, both untouched: the demo stays six MINORs behind for the reason given under 2.8,
|
|
291
|
+
and no workspace here declares the slot, because moving guidance between tiers in this repository's own
|
|
292
|
+
curated layer is its maintainer's change to make. `KNOWN_SPECS` in [`../cli/index.mjs`](../cli/index.mjs)
|
|
293
|
+
and [`../cli/librarian.mjs`](../cli/librarian.mjs) gains `"2.10"` by addition, and the four writers stay at
|
|
294
|
+
`2.7`, since nothing they scaffold declares the slot.
|
|
295
|
+
|
|
296
|
+
**2.9 is a MINOR on 2.8's terms: one optional key and nothing else.** `context` is the machine half of
|
|
297
|
+
[`../core/operating/context.md`](../core/operating/context.md), from proposal `0036`: an always-tier
|
|
298
|
+
budget in tokens and the bytes-per-token ratio it is counted at, with the host whose exact count
|
|
299
|
+
calibrated it. Nothing is removed, renamed, tightened or defaulted, so every 2.8 manifest is a valid 2.9
|
|
300
|
+
manifest unchanged. [`slots.md`](slots.md) argues the key. **It carries `context` alone.** Proposals
|
|
301
|
+
`0034` (`evals` and `telemetry`) and `0038` (its spend keys) each say their keys ride the MINOR this key
|
|
302
|
+
needs and that neither waits for the other; none of them is drafted, and `0034`'s accepted text names no
|
|
303
|
+
version number, so they take the next free MINOR when they are drafted.
|
|
304
|
+
|
|
305
|
+
**Measured across the bump, as for 2.7 and 2.8:** `node cli/doctor.mjs .portulan examples` is GREEN, with
|
|
306
|
+
`.portulan` still on 2.8 and `examples/` on 2.4, both untouched. Neither declares the key, because
|
|
307
|
+
nothing measures the always tier yet and a budget nothing checks reads as a rail. What it costs:
|
|
308
|
+
`KNOWN_SPECS` in [`../cli/index.mjs`](../cli/index.mjs) and [`../cli/librarian.mjs`](../cli/librarian.mjs)
|
|
309
|
+
gains `"2.9"` by addition, and the four writers stay at `2.7`, since nothing they scaffold declares the
|
|
310
|
+
key.
|
|
311
|
+
|
|
206
312
|
**2.8 is a MINOR on the plainest terms in this train's history: one optional key and nothing else.**
|
|
207
313
|
`memory.store.budget.record_kilobytes` — the most bytes any single record may hold — joins the sibling
|
|
208
314
|
`kilobytes` it does not replace. Nothing is removed, renamed, tightened or defaulted, so every 2.7
|
|
@@ -328,6 +434,7 @@ workspace with no constitution slot and a green report.
|
|
|
328
434
|
| A declared pack **resolves** | `doctor` | **Built**. Roots are derived from `tree`, and named roots may be given with `--pack-root`, which **replace** the derived one rather than preceding it, so a from-a-feed demonstration cannot be satisfied by a copy in the local tree at all. A workspace with no `tree` and no named root has its packs reported *unverifiable* rather than failed. Resolution **from a feed is demonstrated**: the checkpoint ritual pack installed from the private `portulan-internal` marketplace and resolved from the install root, which is the same code path the derived one takes. **Discovery is built** ([#123](https://github.com/sleepy-panda-srl/portulan/issues/123)): `--pack-root auto` reads the host's installed-plugin record — in **both** shapes a plugin lands in, packs under `packs/` for a repository-shaped one and categories at the install root for a flat one, which is what this project's own feed ships. Asked-for discovery is **unioned** with the tree-derived root, discovered first, so a workspace composing a cache-installed pack *and* one of its own resolves both. The **named**-root property above is untouched: a named root still replaces the derived one, so a from-a-feed demonstration given one cannot be satisfied by a local copy. What the union trades is the weaker guarantee that `auto` *alone* implied provenance; it no longer does, and each pack's resolution states which root answered instead. Asking for a named root **and** `auto` together is refused (exit 2) rather than silently resolved to one of them. Discovery runs on the **unasked** path too, because `--pack-root` is *optional where discovery finds a root*. `auto` now selects the strict degrade (asked-and-could-not-look is exit 2) rather than unlocking discovery. Two things bound it, and neither makes a bare run host-independent — `doctor` is a per-host capability report and a bare verdict does move with the machine. First, every **required** check **names** its root, which replaces every other source, so it cannot consult the host at all. Second, `doctor`'s note-vs-fail keying is on a root's **origin**, so discovery can turn a note into a **resolution** and never a **miss** into a failure — a miss under a root the workspace or the caller claimed stays a FAIL whatever is installed. It softens nothing: a discovered copy that resolves and is *invalid* still fails, with its origin named. |
|
|
329
435
|
| A pack manifest conforms to the Pack Definition | `doctor` | **Built**. Same validator, same subset, a different schema and version train. |
|
|
330
436
|
| A pack's skills and personas are opened and validated | `doctor` | **Built**, on the maintainer's ruling that row 7's validation reads broad. A skill's frontmatter (`name` kebab-case, `description` non-empty — the two a host reads before loading a body); a persona against the five-part contract, **including the memory scope** `core/operating/memory.md` promised would be validated at this milestone; and the tier no role may claim. Containment is checked **after resolution**, because opening these paths is what made an escaping value stop being inert. Three honest limits, each measured rather than assumed. The skills walk stops **three levels down**, matching `cli/plugin-lint.mjs`'s bound so two walkers over the same key cannot disagree, and it **reports the directories it did not descend into** rather than counting a root it never opened as empty. The Prohibited check is a prose heuristic defeated by any negation **in the same block** — a bullet list counts as one block — and the structural fix is a declared reach *field*, a contract change that is not built. And the five-part check is **presence**, not content: a persona whose sections are all empty passes, so a declared memory scope is read and not honoured. |
|
|
437
|
+
| A persona's declared context fields — `model`, `effort`, `omitClaudeMd`, `cacheLifetime` | `doctor` · `compile` | **Built** for a persona a composed pack ships, with no schema version moved, since no persona file is governed by either schema: the fields belong to the persona contract [`../core/personas/README.md`](../core/personas/README.md) fixes, and are optional. `doctor` fails a malformed value and `compile` refuses one; neither defaults any. `compile` writes the declared fields into the role's hand-written binding, `agents/<name>.md` in the tree, byte-compared under `--check`, and prints a degradation line for each field a host cannot express. Claude Code loads `agents/` only from a plugin root, so in a tree that is not one the compiled block is loaded by nothing, and that degradation line says so; in one that is, it loads only while the plugin is installed and enabled, which `compile` cannot see. **Not built**: writing `.claude/agents/`, where a repository that is not a plugin loads its agents; a binding where none exists, which is reported unbound; and the compiling of a core persona's binding. |
|
|
331
438
|
| Pack gate fragments merge **tighten-only** | [`../cli/compile.mjs`](../cli/compile.mjs) | **Built**. Two layers and two axes: `auto` is absent from the Pack Definition's tier enum, so a demotion to unattended is unexpressible; the comparison against the composed base is the compiler's, and both a weakened **tier** and a changed **action** throw rather than being dropped. The action half is the one a tier-only check misses — raising the tier while swapping the matcher removes the gate and still reads as a tightening. |
|
|
332
439
|
| A pack declaring a version ahead of `doctor` | `doctor` | **Built**. Refused rather than graded, symmetric with the workspace train, and on the Pack Definition's own `$id`. |
|
|
333
440
|
| Path slots resolve to real files | `doctor` | **Built.** File-versus-directory too; a slot resolving outside the workspace is reported, never failed. |
|
|
@@ -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
|
+
};
|