@sleepy_panda_srl/portulan 0.1.0 → 0.1.1

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 CHANGED
@@ -1,35 +1,44 @@
1
1
  # Portulan
2
2
 
3
- **An operating framework for agentic engineering** — the tailored context, standards, gates, and
4
- institutional memory a team needs so any coding agent works *their* way.
3
+ **An operating framework for agentic engineering** — the tailored context, standards, gates and
4
+ institutional memory a team needs so that any coding agent works *their* way.
5
5
 
6
- Portulan is an open-core product by [Sleepy Panda SRL](https://sleepypanda.ro). The public engine,
7
- spec, CLI, and a demo workspace live here, and the repository doubles as a public Claude Code plugin
8
- marketplace. Commercial value lives in private feeds — customer workspaces and premium packs
9
- delivered as private-marketplace plugins.
6
+ [![npm](https://img.shields.io/npm/v/%40sleepy_panda_srl%2Fportulan?logo=npm&label=npm)](https://www.npmjs.com/package/@sleepy_panda_srl/portulan)
7
+ [![License](https://img.shields.io/badge/license-Apache--2.0-blue)](LICENSE)
8
+ [![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen?logo=node.js)](package.json)
9
+
10
+ Portulan is an open-core product by [Sleepy Panda SRL](https://sleepypanda.ro). The engine, the
11
+ Workspace Definition, the CLI and a complete demo workspace are public and live in this repository,
12
+ which doubles as a Claude Code plugin marketplace. Commercial value lives in private feeds — customer
13
+ workspaces and premium packs delivered as private-marketplace plugins.
10
14
 
11
15
  > **The name.** *Portulan* is the Romanian word for a portolan chart — a mariner's map compiled from
12
16
  > the accumulated observations of real voyages: hard-won local knowledge, more valuable with every
13
17
  > trip. The engine is the chartmaking method; your workspace is your portulan.
14
18
 
19
+ ---
20
+
15
21
  ## Status
16
22
 
17
- Pre-release, and **public** — reading, cloning, forking and the marketplace install path all need no
18
- account of ours. _(Visibility is Gated and has moved three times: public 27 July – 3 August 2026, private
19
- 3 August – 17 August 2026, public since 17 August 2026. It is a live setting, so trust a fresh check
20
- over this line.)_
21
- The newest release entry
22
- is **`0.2.0`**; what it contains — and, just as usefully, what it does not — is
23
- [`CHANGELOG.md`](CHANGELOG.md). The engine in `core/`, the Workspace Definition in `spec/`, two
24
- workspaces, the validators that check them, the enforcement compiler and the memory lifecycle, and the
25
- plugin packaging in `plugin/` and `.claude-plugin/` are authored; the remaining directories below fill in
26
- milestone by milestone, and the `0.` major means nothing here is stable yet. The living map is
27
- [`docs/plan.md`](docs/plan.md).
23
+ **Current release: `0.1.1`** — recorded in [`CHANGELOG.md`](CHANGELOG.md), which is also the honest
24
+ account of what the release does *not* contain.
25
+
26
+ This is pre-release software. The leading `0.` is meant literally: nothing here is stable yet, and
27
+ the surface may change between minor versions. What is authored today is the engine in [`core/`](core/),
28
+ the Workspace Definition in [`spec/`](spec/), two complete workspaces, the validators that check them,
29
+ the enforcement compiler, the memory lifecycle and the plugin packaging. The remaining directories
30
+ fill in milestone by milestone; [`docs/plan.md`](docs/plan.md) is the living map of that work.
31
+
32
+ The repository is **public** — reading, cloning, forking and installing the plugin need no account of
33
+ ours. Visibility is a live repository setting, so trust a fresh look at the repository page over this
34
+ sentence.
35
+
36
+ ## Install
28
37
 
29
- ## Try it
38
+ ### As a Claude Code plugin
30
39
 
31
- This repository **is** a Claude Code plugin marketplace, so installing it needs nothing but the two
32
- lines below — no GitHub account, no token, no clone. (You need Claude Code itself, of course.)
40
+ This repository *is* a plugin marketplace, so installing it takes two commands — no clone, no token,
41
+ no GitHub account.
33
42
 
34
43
  ```bash
35
44
  claude plugin marketplace add sleepy-panda-srl/portulan
@@ -39,49 +48,130 @@ claude plugin marketplace add sleepy-panda-srl/portulan
39
48
  claude plugin install portulan@portulan
40
49
  ```
41
50
 
42
- Then, inside Claude Code, run the `/portulan` slash command to boot the engine. _(Measured on 2026-08-18 into an isolated `CLAUDE_CONFIG_DIR`: the
43
- marketplace resolved, the plugin installed, and the payload landed in the cache with `core/engine.md`
44
- present. What that gives you is the **engine** — a workspace is still yours to draft, which is what
45
- [`cli/init.mjs`](cli/init.mjs) is for.)_
51
+ Then run `/portulan` inside Claude Code to boot the engine. That gives you the **engine**; your
52
+ workspace is still yours to draft, which is what `portulan init` is for.
53
+
54
+ ### As a command-line tool
55
+
56
+ ```bash
57
+ npx @sleepy_panda_srl/portulan doctor examples
58
+ ```
59
+
60
+ Requires Node.js 20 or newer. The package has zero runtime dependencies.
61
+
62
+ ### From GitHub Packages
63
+
64
+ Every release from **`v0.1.1`** onward also publishes the same tree to GitHub Packages, where the
65
+ registry requires the scope to match the repository owner — so there it is named
66
+ **`@sleepy-panda-srl/portulan`**, with hyphens. Releases before that one are on npmjs only.
67
+
68
+ **This route needs a GitHub token even though the package is public**, which is GitHub Packages' rule
69
+ rather than ours — so the registry line alone is not enough, and the authentication is shown rather
70
+ than alluded to. In an `.npmrc`:
71
+
72
+ ```
73
+ @sleepy-panda-srl:registry=https://npm.pkg.github.com
74
+ //npm.pkg.github.com/:_authToken=${NPM_GITHUB_PACKAGES_TOKEN}
75
+ ```
76
+
77
+ ```bash
78
+ NPM_GITHUB_PACKAGES_TOKEN=<your token> npm install @sleepy-panda-srl/portulan
79
+ ```
80
+
81
+ The token is a **personal access token of your own** — not the `GITHUB_TOKEN` that GitHub Actions
82
+ provides, which exists only inside a workflow run — and it needs only the `read:packages` scope. npm
83
+ expands the `${...}` reference from the environment, so the token itself never has to be written into
84
+ the file.
85
+
86
+ The npmjs path above needs no account of ours and remains the documented one; this is an additional
87
+ route for people already inside GitHub's authentication. The two registries serve the same tree under
88
+ two names, so the tarballs are *not* byte-identical: packed both ways they differ in exactly one line
89
+ of one file — `package.json`'s `name` — at 74 files each side.
90
+
91
+ ## Quick start
92
+
93
+ Read before you install anything. [`examples/`](examples/) is a complete fictional workspace — two
94
+ products, read end to end — and validating it from a clone is one command:
95
+
96
+ ```bash
97
+ node cli/doctor.mjs examples
98
+ ```
46
99
 
47
- Or read before you install anything: [`examples/`](examples/) is a complete fictional workspace, and
48
- `node cli/doctor.mjs examples` from a clone watches it validate.
100
+ To adopt Portulan in your own repository, `portulan init` interviews you, scans the codebase and
101
+ drafts a workspace; `portulan doctor` validates it; `portulan compile` turns your gate policy into
102
+ host enforcement.
49
103
 
50
- ## Layout
104
+ ## Command-line interface
105
+
106
+ `npx @sleepy_panda_srl/portulan` is a live path as of 2026-08-18 — published at `0.1.0` and
107
+ demonstrated from outside any checkout, which closes
108
+ [#242](https://github.com/sleepy-panda-srl/portulan/issues/242). At the publish, the registry's tarball
109
+ and a fresh `npm pack` of that tree hashed identically and every file compared byte-for-byte; that is a
110
+ hand measurement recorded on [#149](https://github.com/sleepy-panda-srl/portulan/issues/149), and it is
111
+ a fact about `d6498f0` rather than a standing property of `main`, which has since moved. What *is*
112
+ railed on every commit is the tree-side half: the `pack-identity` recipe refuses any packed file that
113
+ differs from its staged blob. Running `node cli/<tool>.mjs` from a clone is still how this repository
114
+ measures everything, and still works.
115
+
116
+ The [`portulan`](cli/portulan.mjs) entry point arrived at milestone 7 and has dispatched every name
117
+ [`docs/vision.md`](docs/vision.md) gives it since session 9. The table below is the enumeration, and
118
+ `portulan --help` is the tree's own answer — a count written here in words would be one more
119
+ hand-maintained figure, which is the defect this cell has already shipped twice:
120
+
121
+ | Command | What it does |
122
+ |---|---|
123
+ | [`init`](cli/init.mjs) | Interview plus codebase scan, producing a drafted workspace |
124
+ | [`doctor`](cli/doctor.mjs) | Validate a workspace against the Workspace Definition |
125
+ | [`compile`](cli/compile.mjs) | Turn gates and verify recipes into host enforcement |
126
+ | [`vendor`](cli/vendor.mjs) | Materialise a workspace where it is needed, in either direction |
127
+ | [`index`](cli/index.mjs) | Rebuild the declared indexes over memory, handoffs and personas |
128
+ | [`upgrade`](cli/upgrade.mjs) | Migrate a workspace across Workspace Definition versions |
129
+ | [`new`](cli/new.mjs) | Scaffold a skill, persona, pack, workspace, gate policy or repo card |
130
+ | [`feedback`](cli/feedback.mjs) | File a bug, improvement or feedback issue you previewed first |
131
+
132
+ Beside the eight sit tools that are deliberately not subcommands, and the compiled-hook runners, which
133
+ are not subcommands at all. [`cli/README.md`](cli/README.md) is the single carrier of that wider roster.
134
+
135
+ ## Repository layout
51
136
 
52
137
  | Path | What lives here |
53
138
  |---|---|
54
- | `core/` | The engine: operating docs, personas, universal skills, templates |
55
- | `packs/` | Composable packs — `stacks/`, `tools/`, `rituals/` |
56
- | `spec/` | The Workspace Definition: schema, docs, migrations |
57
- | `plugin/` | [The Claude Code adapter](plugin/): the `/portulan` boot skill, and why the agent bindings below sit at the root rather than here |
58
- | `agents/` | [The three personas bound to this host's tools](agents/) — `implementer`, `reviewer`, `librarian`. A platform-fixed location: the default agents directory of a plugin whose root is this repository. The doctrine they bind stays in [`core/personas/`](core/personas/) |
59
- | `.claude-plugin/` | The [plugin](.claude-plugin/plugin.json) and [marketplace](.claude-plugin/marketplace.json) manifests that make this repository an installable plugin feed |
60
- | `cli/` | The `npx` CLI: the eight `docs/vision.md` names. **`npx` is the shape, not a live path — publishing is Gated and has not happened, so the package is not on the registry, and the invocation this repository measures everything through is `node cli/<tool>.mjs` from a clone; [`.portulan/identity.md`](.portulan/identity.md) carries that fact and [#242](https://github.com/sleepy-panda-srl/portulan/issues/242) the open routing question.** The [`portulan`](cli/portulan.mjs) entry point exists as of milestone 7 and dispatches **all eight** since session 9 — [`init`](cli/init.mjs), [`doctor`](cli/doctor.mjs), [`compile`](cli/compile.mjs), [`vendor`](cli/vendor.mjs), [`index`](cli/index.mjs), [`upgrade`](cli/upgrade.mjs), [`new`](cli/new.mjs), [`feedback`](cli/feedback.mjs). _(This cell said "five" and then listed four, and later "seven" while `upgrade` was unbuilt — the kind of arithmetic a reader checks and a rail cannot.)_ Beside them sit tools that are off the eight by the maintainer's call, and the compiled-hook runners, which are not subcommands at all — [`cli/README.md`](cli/) is the one carrier of that roster, and this cell named a different three from the one `portulan.mjs` named while four were on disk |
61
- | `.claude/` | [The compiled enforcement](.claude/settings.json) — permissions and hooks generated from [`.portulan/gates.json`](.portulan/gates.json). Generated, committed so it is reviewable, and held to its source by the `compile` verify recipe, which holds the compiled platform floor in [`.portulan/compile/`](.portulan/compile/) the same way. Nothing else in this directory is tracked |
139
+ | `core/` | [The engine](core/): operating doctrine, personas, universal skills, templates |
140
+ | `spec/` | [The Workspace Definition](spec/) — schema, documentation, migrations |
141
+ | `cli/` | [The Node CLI](cli/) described above, plus its tests and the compiled-hook runners |
142
+ | `packs/` | [Composable packs](packs/) — `stacks/`, `tools/`, `rituals/` |
143
+ | `plugin/` | [The Claude Code adapter](plugin/): the `/portulan` boot skill |
144
+ | `agents/` | [The three personas bound to this host's tools](agents/) — `implementer`, `reviewer`, `librarian`. A platform-fixed location; the doctrine they bind stays in [`core/personas/`](core/personas/) |
62
145
  | `examples/` | [A fictional, public demo workspace](examples/) — two products, read end to end |
146
+ | `docs/` | [`vision.md`](docs/vision.md) (the constitution), [`plan.md`](docs/plan.md) (the milestone map) and [`milestones/`](docs/milestones/) — one file per milestone, holding amendment arguments, session notes and close evidence |
147
+ | `evals/` | [A README today](evals/), and nothing else. The golden tasks, the A/B harness and the CI eval gate arrive at milestone 8 |
63
148
  | `.portulan/` | [This repository's own workspace](.portulan/) — Portulan applied to building Portulan |
64
- | `evals/` | Golden tasks, the A/B harness, the CI eval gate |
65
- | `.github/` | CI workflows — runs every verify recipe the workspace **yields** — its own, plus those its composed packs contribute — on every pull request — and the [Dependabot config](.github/dependabot.yml) that watches the Actions pins those workflows are held to |
66
- | `docs/` | [`vision.md`](docs/vision.md) (the constitution), [`plan.md`](docs/plan.md) (the milestone map), and [`milestones/`](docs/milestones/) — **one file per milestone**, zero-padded so they sort, holding the amendment arguments, session notes and close evidence that used to live inside the table's rows. The row is the law; the file is the legislative history |
149
+ | `.claude-plugin/` | The [plugin](.claude-plugin/plugin.json) and [marketplace](.claude-plugin/marketplace.json) manifests that make this repository an installable feed |
150
+ | `.claude/` | [The compiled enforcement](.claude/settings.json) — permissions and hooks generated from [`.portulan/gates.json`](.portulan/gates.json), committed so it stays reviewable and held to its source by the `compile` verify recipe |
151
+ | `.github/` | [CI workflows](.github/), which run every verify recipe the workspace yields, and the [Dependabot config](.github/dependabot.yml) that watches their Action pins |
67
152
 
68
- ## Read next
153
+ ## Documentation
69
154
 
70
- - [`docs/vision.md`](docs/vision.md) — the constitution: what Portulan is, the thesis, the non-goals.
71
- - [`docs/plan.md`](docs/plan.md) — the living milestone map and build protocol.
155
+ - [`docs/vision.md`](docs/vision.md) — the constitution: what Portulan is, the theses, the non-goals.
156
+ - [`docs/plan.md`](docs/plan.md) — the living milestone map and the build protocol.
157
+ - [`spec/`](spec/) — the Workspace Definition your own policy layer is validated against.
158
+ - [`CHANGELOG.md`](CHANGELOG.md) — every release and what changed in it.
72
159
 
73
160
  ## Contributing
74
161
 
75
- Portulan is built as an open-core product and is **not** open to outside code contributions. Reading,
76
- cloning and forking are what the licence allows; bugs, proposals and feedback go through the
77
- [issue forms](https://github.com/sleepy-panda-srl/portulan/issues/new/choose); and code lands only
78
- through team members.
162
+ Portulan is **not open to outside code contributions**, and that is a deliberate posture rather than a
163
+ temporary state. Reading, cloning and forking are what the licence allows; bugs, proposals and feedback
164
+ go through the [issue forms](https://github.com/sleepy-panda-srl/portulan/issues/new/choose); code lands
165
+ only through team members.
166
+
167
+ Those open paths are open to anyone and need no permission from us; the closed one is closed by choice
168
+ rather than by reach. [`CONTRIBUTING.md`](CONTRIBUTING.md) has the whole of it, including why.
169
+
170
+ ## Security
79
171
 
80
- **Those paths are open to anyone, and the closed one is closed by choice rather than by reach** — the
81
- tree and the issue forms need no account of ours and no permission from us, and code still lands only
82
- through team members. That distinction is the whole posture, and it reads the same whichever way
83
- visibility points. [`CONTRIBUTING.md`](CONTRIBUTING.md) has the whole of it, including why.
172
+ Please do not report a security issue in an issue. [`SECURITY.md`](SECURITY.md) is the policy: the
173
+ channels in the order to try them, what is in scope, and what response you can actually expect.
84
174
 
85
175
  ## License
86
176
 
87
- [Apache-2.0](LICENSE) © 2026 Sleepy Panda SRL.
177
+ [Apache-2.0](LICENSE) © 2026 Sleepy Panda SRL. See [`NOTICE`](NOTICE).
package/cli/README.md CHANGED
@@ -8,15 +8,20 @@ entry point exists:
8
8
  [`portulan.mjs`](portulan.mjs), reached as `portulan <subcommand>` through the `bin` in the
9
9
  repository's `package.json`.
10
10
 
11
- **`npx` names a path that is not open yet, and this file states that once rather than hedging every
12
- mention below.** Publishing is a Gated act and has not happened, so `@sleepy_panda_srl/portulan` **is
13
- not on the registry today** — `npx @sleepy_panda_srl/portulan` resolves nothing. `package.json`
14
- declares the `bin` that path would use, and that `bin` is reachable now from a working copy; what every
15
- measurement in this repository has actually been taken through is `node cli/<tool>.mjs` from a clone.
16
- [`../.portulan/identity.md`](../.portulan/identity.md) is the carrier of that fact and of what the
17
- `npx` path is measured to buy when it opens — the same bytes, `npm pack` against `git show`. The
18
- routing question the gap leaves open is
19
- [#242](https://github.com/sleepy-panda-srl/portulan/issues/242).
11
+ **`npx` is a live path as of 2026-08-18, and this file states that once rather than hedging every
12
+ mention below.** `@sleepy_panda_srl/portulan@0.1.0` is on the registry, and
13
+ `npx @sleepy_panda_srl/portulan` resolves — demonstrated from a directory containing no git repository
14
+ at all, which is what milestone 7 named as undemonstrated and
15
+ [#242](https://github.com/sleepy-panda-srl/portulan/issues/242) was filed to route.
16
+ `node cli/<tool>.mjs` from a clone remains the invocation most measurements in this repository were
17
+ taken through, and it still works; what changed is that it is no longer the only one.
18
+ [`../.portulan/identity.md`](../.portulan/identity.md) carries what the `npx` path buys — the same
19
+ bytes — and that is now **measured against the registry rather than against `npm pack` alone**: the
20
+ published tarball and a fresh `npm pack` hash to the same value, and every file in them compares
21
+ byte-for-byte
22
+ ([#149](https://github.com/sleepy-panda-srl/portulan/issues/149)). _(The roster the package ships is
23
+ narrower than the tree and is not restated here as a number — `npm pack --dry-run` derives it, and a
24
+ hand-copied count is the thing that keeps going wrong in this file.)_
20
25
 
21
26
  **All eight dispatch, as of milestone 7 session 9.** `doctor`, `compile` and `index` exist because
22
27
  milestones 2, 4 and 5 needed them; `init` was built at milestone 7 session 1, `new` at session 2,
@@ -38,8 +43,9 @@ lasted one pull request. Worth one sentence rather than none, because the *shape
38
43
  may be licensed by the row before the constitution catches up, and while that holds, the tool has to say
39
44
  which document names it.
40
45
 
41
- Beside those eight sit **seven** runnable tools that are on none of the lists — `plugin-lint`,
42
- `librarian`, `control-chars`, `discover`, `rule-carriers`, `pack-version` and `eval-bundle` — because
46
+ Beside those eight sit **eight** runnable tools that are on none of the lists — `plugin-lint`,
47
+ `librarian`, `control-chars`, `discover`, `rule-carriers`, `pack-version`, `eval-bundle` and
48
+ `pack-identity` — because
43
49
  milestone 3, milestone 5, issue
44
50
  [#68](https://github.com/sleepy-panda-srl/portulan/issues/68), milestone 7's plugin-cache discovery,
45
51
  [`../.portulan/verify/rule-carriers.sh`](../.portulan/verify/rule-carriers.sh), the
@@ -142,11 +148,13 @@ check on it.)_
142
148
  | [`gate.mjs`](gate.mjs) | **The PreToolUse gate runner** — the *explanation* half of the enforcement compiler, wired by [`compile.mjs`](compile.mjs) into `.claude/settings.json`. It reads the host's hook payload on stdin, finds which rule of the policy the workspace **yields** the attempted action matches, and returns that rule's own sentence as the decision reason. *Yields*, not *declares*: [`../.portulan/gates.json`](../.portulan/gates.json) **plus the fragments its packs contribute**, composed through `compile`'s own `packContributions`/`composeFragments` so there is no second composer to drift — this row said *declares* until [#269](https://github.com/sleepy-panda-srl/portulan/issues/269), and so did the runner. Where two rules match one call it answers with the **strongest** tier, not the first listed — composition makes that systematic, since added rules are appended and so always lose a first-match tie, but a single-file policy listing the weaker rule first diverged too. Strengthen-only: `ask` may become `deny`, never the reverse. **This file is not the gate** — the host's own matcher is; this says *why*, so a refusal arrives as a reason rather than a wall. |
143
149
  | [`gate.test.mjs`](gate.test.mjs) | Its test suite, and the exception to "written first" for the same reason [`stop-gate.test.mjs`](stop-gate.test.mjs) is: it arrived with [#269](https://github.com/sleepy-panda-srl/portulan/issues/269), on a runner nothing had tested directly. **Every case spawns the real binary** with a payload on stdin, because the defect it closes was never in `decide` — it was in which rules reached `decide`, and a unit test would have passed on every day the hook was blind. |
144
150
  | [`stop-gate.mjs`](stop-gate.mjs) | **The Stop-gate runner** — *done* is not a thing an agent may simply declare. Wired by [`compile.mjs`](compile.mjs) into `.claude/settings.json` as a `Stop` hook: when an agent tries to end its turn, this runs the workspace's default verify recipe and blocks the ending if the recipe is not green. [`../core/operating/verification.md`](../core/operating/verification.md) specified the contract at milestone 1 and named milestone 4 as where the runner arrives; this is that runner. |
145
- | [`stop-gate.test.mjs`](stop-gate.test.mjs) | The exception to "written first": it covers the Stop-gate runner ([`stop-gate.mjs`](stop-gate.mjs)), and it exists because a supervisor found a fail-open and a forever-block in a runner nothing tested at all. Its cap arithmetic and date handling — deliberately not its I/O. |
151
+ | [`stop-gate.test.mjs`](stop-gate.test.mjs) | The exception to "written first": it covers the Stop-gate runner ([`stop-gate.mjs`](stop-gate.mjs)), and it exists because a supervisor found a fail-open and a forever-block in a runner nothing tested at all. Its cap arithmetic and date handling, and — since [#220](https://github.com/sleepy-panda-srl/portulan/issues/220) — the **did-work signals**, which spawn the real binary against real git fixtures for the reason [`gate.test.mjs`](gate.test.mjs) gives: the defect was that real git in a rebase-merging repository behaves otherwise than the signal assumed, and a stubbed runner is a copy of that assumption. What still has no directed case: a recipe that reds or cannot run, driven through the spawned binary, and the handoff-listing edge shapes — both are exercised here only as preconditions of the cases above. |
146
152
  | [`librarian.mjs`](librarian.mjs) | The scheduled librarian's pass: reindex over both series, record age from git, the sealed-stamp re-validation nag, proposal nagging, demotion drafts, **mining** — incidents nothing in the curated layer points back at, and paths pull-request reviewers keep leaving findings on — and **consolidation**, read as budget distance plus the records citing one incident. The one tool here that reads **history** — `doctor` reads the tree and says so — which is why it belongs to a scheduled job rather than to a verify recipe: a check that reads history is a false-red generator in a shallow checkout. It renders no verdict, so it has no exit 1, and it **writes nothing itself**: the command writes, and only after the pass's own handoff is on disk, because that handoff is a member of the series one of the indexes covers. |
147
153
  | [`control-chars.mjs`](control-chars.mjs) | **The rail on bytes a reader cannot see**, added for [#68](https://github.com/sleepy-panda-srl/portulan/issues/68): no tracked file may carry a byte in the C0 range other than TAB and LF, nor DEL. It reads a `Buffer` and **never decodes**, because decoding invalid UTF-8 yields U+FFFD — which both hides bytes and invents a character that was not there. That rule holds end to end and cost three review rounds to make true: the file contents, the file list on stdin, and a symlink's target all arrive as bytes, and a path is kept only if it round-trips through UTF-8. A binary asset is exempt **by name**, never by a content test: every binary sniff in use is keyed on NUL, so a sniff would have skipped the one file this exists to catch. The declaration is audited three ways — stale, never-read, or dead — each exit `2`. |
148
154
  | [`control-chars.test.mjs`](control-chars.test.mjs) | Its test suite, written first. Its fixtures build their forbidden bytes with `String.fromCharCode` rather than carrying them literally — a suite that carried one would be a tracked file the check must go red on, and a rail red on its own tests is a rail somebody switches off. Its last group scans this repository's own tracked set. |
149
155
  | [`pack-version.mjs`](pack-version.mjs) | **A change to a pack's `contributes` must move that pack's `portulan.version`** — the maintainer's ruling on [#265](https://github.com/sleepy-panda-srl/portulan/issues/265), arm 3, including that a **prose-only** edit to a fragment's `reason` counts. The **second** tool here that reads history rather than the tree, and the comparison is **three-dot** — the merge-base of the base ref and `HEAD` — so work that landed on the base is never attributed to this branch. Manifests are compared as **values**, so reindenting or reordering keys is correctly not a change. The bundle version in `packs/.claude-plugin/plugin.json` is out of scope by measurement: it is one field and the packs are many. |
156
+ | [`pack-identity.mjs`](pack-identity.mjs) | **Every file `npm pack` would emit is byte-identical to its staged blob (`git show :<path>`), and every one is tracked at all** — the rail [#149](https://github.com/sleepy-panda-srl/portulan/issues/149) asked for, built 2026-08-18 after the first publish measured the property by hand. It compares against the **tree**, not the registry: the registry form needs the network and a published version, so it cannot run before a first publish or offline, and a recipe that cannot run stops being run. This owns the half that can drift silently on any commit; the registry half stays a hand measurement recorded on #149 at each publish. `package.json` is compared like every other file rather than exempted, because the 2026-08-13 measurement asserted identity "`package.json` included, with no exemption" and an exemption here would quietly retire that claim. Could-not-run is **exit 2** — npm absent or unusable is not a finding about the bytes. |
157
+ | [`pack-identity.test.mjs`](pack-identity.test.mjs) | The rail's suite, added on a Copilot note that it had shipped without one while every comparable rail here has a suite. Exercises all four contracted states **positively** — green, a drifted byte, an untracked packed file, and could-not-run — because a failure path nobody has run is a failure path nobody has seen work. Two of its cases are the review's own findings turned into tests: a **staged** edit must compare clean, or the rail is red before every commit and contradicts [`../.portulan/dod.md`](../.portulan/dod.md); and a filename beginning with `-` must be compared rather than read as an option, which the `cat` shell-out this rail first shipped with would have done. |
150
158
  | [`pack-version.test.mjs`](pack-version.test.mjs) | Its test suite, written first, on **real `git init` repositories** rather than a stubbed `execFileSync` — the three-dot property cannot be observed at all without two branches and a merge-base. The shallow-clone refusal is asserted on **both** shapes a shallow clone takes, and is guarded by a full-depth clone of the same fixture reaching the red, so the `2` is the depth rather than the fixture. |
151
159
  | [`eval-bundle.mjs`](eval-bundle.mjs) | **The evaluation-bundle cutter** — a named-recipient stamped copy of the shippable payload, cut from an explicitly named commit, ported into the tree on the maintainer's ruling of 2026-08-17. The payload partition and the machine-read license census are **pinned in both directions**, and **two different steps refuse two different things**, which is worth keeping straight because each sends an operator somewhere else: the **census** runs on the freshly materialised cut and refuses an Apache assertion in a file that is not rostered in `APACHE_MANIFESTS`, or a rostered file that has stopped asserting; the **guard** runs on the finished cut and — **inverted on the maintainer's ruling of 2026-08-18 ([#284](https://github.com/sleepy-panda-srl/portulan/issues/284))** — refuses any machine-read `license` value that is **not** Apache-2.0, plus any issuer-machinery path that leaked in, diagnosing a drifted rostered manifest separately from an unrostered one and from a failed self-exclusion. Nothing rewrites licence metadata and `LICENSE` ships: the bundle is the public tree's bytes under the public tree's licence, so the README's own License section is correct as written and its link resolves. The issuance ledger, and all recipient data, stay OUTSIDE this repository; evaluation terms render from [`eval-license.template.md`](eval-license.template.md) **at the payload commit**, pinning payload and terms to one sha; `--check` (what [`../.portulan/verify/eval-bundle.sh`](../.portulan/verify/eval-bundle.sh) runs) cuts the **index**, wrapped as an unreferenced probe commit, for a fixture recipient into scratch it always deletes. **Deliberately not a ninth `portulan` subcommand**, on `discover`'s precedent above — and deliberately **self-excluded from the payload**, so an evaluee never receives the issuer's machinery. |
152
160
  | [`eval-bundle.test.mjs`](eval-bundle.test.mjs) | Its test suite. The rosters are pinned with the suite's **own** instruments — `git ls-tree` and `git grep`, never the module's helpers — because a pin that asks the subject to measure itself tests agreement, not truth; the self-exclusion is exercised **positively** on a fixture repository that plants files at exactly the excluded paths; the terms pin is **demonstrated** (an old commit stamps its old template whatever the tree says now); and the plumbing transport is asserted byte-identical, modes included, to `git archive \| tar -x`, so the port's one mechanical delta is demonstrated rather than trusted. |
package/cli/init.mjs CHANGED
@@ -986,8 +986,9 @@ exit 2
986
986
  *
987
987
  * Unlike `workspace.sh` this one is **finished** — it checks a real thing on the day it is written,
988
988
  * because the index it holds current is written beside it. What it cannot promise is that the tool is
989
- * reachable: this package is not published, so on an adopter's CI none of the three locations may
990
- * answer and the recipe's expected first state there is **exit 2**. That is the honest code and it is
989
+ * reachable: the package is published, but a drafted workspace does not install it, so on an adopter's
990
+ * CI none of the three locations may answer and the recipe's expected first state there is **exit 2**.
991
+ * Publication widened the ways it CAN be reachable; it did not make it reachable by default. That is the honest code and it is
991
992
  * said in `verify/README.md` as well, rather than discovered when a pipeline goes amber.
992
993
  *
993
994
  * The third location is the bundle this workspace was drafted from, and it is written in as a
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ // Every file `npm pack` emits is byte-identical to the tracked blob it came from.
3
+ //
4
+ // The rail [#149](https://github.com/sleepy-panda-srl/portulan/issues/149) asked for, in the form that
5
+ // can actually run on every pull request. `.portulan/identity.md` states the property — **the `npx`
6
+ // path IS an install, and it installs the SAME BYTES** — and until 2026-08-18 nothing checked it: the
7
+ // figure was re-measured by hand three times (72 files, then 114, then 73) and was wrong in the tree
8
+ // twice between those measurements.
9
+ //
10
+ // ## Why this compares against the TREE and not against the registry
11
+ //
12
+ // The measurement taken at the first publish compared the published tarball to a fresh `npm pack`, and
13
+ // they hashed the same. That is the stronger claim and it is recorded on #149 — but it is the wrong
14
+ // shape for a recipe: it needs the network, it needs a published version to exist, and it cannot run
15
+ // before the first publish or on a machine offline. A recipe that cannot run is a recipe that stops
16
+ // being run.
17
+ //
18
+ // What this checks instead needs neither: `npm pack --dry-run --json` lists what the package would
19
+ // contain, and every one of those paths must be byte-identical to its STAGED blob, `git show :<path>`
20
+ // — the index rather than HEAD, for the reason the comment on `blobAt` gives. That is the
21
+ // property the registry comparison DEPENDS on — if the packed bytes match the tree, and the published
22
+ // tarball matches a fresh pack, then the published bytes are the tree's. This rail owns the first half,
23
+ // which is the half that can drift silently on any commit; the second half is a hand measurement taken
24
+ // at each publish, which is what #149 asked for and what its comment records.
25
+ //
26
+ // ## Exit codes, per ../.portulan/memory/verify-preconditions-fail-closed.md
27
+ //
28
+ // 0 every packed file matches its staged blob
29
+ // 1 a finding: at least one packed file differs from what is staged, or is not tracked at all
30
+ // 2 could not run: `npm pack` unusable, not a git repository, or HEAD unreadable
31
+ //
32
+ // The 2 matters more here than in most recipes. `npm pack` shells out to npm, which can be absent,
33
+ // offline-hostile, or a different major version — and a rail that reports "the bytes differ" when what
34
+ // happened is "npm did not run" sends someone hunting a drift that does not exist.
35
+
36
+ import { execFileSync } from "node:child_process";
37
+ import fs from "node:fs";
38
+ import path from "node:path";
39
+ import { pathToFileURL } from "node:url";
40
+ import process from "node:process";
41
+
42
+ class CannotRun extends Error {}
43
+
44
+ const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024, ...opts });
45
+
46
+ /** What the package would contain, as repo-relative paths. */
47
+ function packedPaths(root) {
48
+ let out;
49
+ try {
50
+ out = run("npm", ["pack", "--dry-run", "--json"], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }).toString("utf8");
51
+ } catch (error) {
52
+ throw new CannotRun(`\`npm pack --dry-run --json\` did not run: ${error.message.split("\n")[0]}`);
53
+ }
54
+ let parsed;
55
+ try {
56
+ parsed = JSON.parse(out);
57
+ } catch {
58
+ throw new CannotRun("`npm pack --dry-run --json` printed something that is not JSON");
59
+ }
60
+ const files = parsed?.[0]?.files;
61
+ if (!Array.isArray(files) || files.length === 0) throw new CannotRun("`npm pack` reported no files — refusing to report a green over an empty roster");
62
+ // The SHAPE is checked, not assumed. `files` is npm's JSON, and a changed schema or an odd entry
63
+ // would otherwise yield `undefined` paths that crash further down — surfacing as an uncaught
64
+ // exception the runner reads as a red FINDING about the package. npm's output is an input like any
65
+ // other; a roster this tool cannot read is could-not-run.
66
+ const paths = [];
67
+ for (const [i, entry] of files.entries()) {
68
+ const rel = entry?.path;
69
+ if (typeof rel !== "string" || rel.length === 0) {
70
+ throw new CannotRun(`\`npm pack\` reported an entry at index ${i} with no usable \`path\` — its JSON shape is not what this rail reads`);
71
+ }
72
+ paths.push(rel);
73
+ }
74
+ return paths;
75
+ }
76
+
77
+ // The blob is read from the INDEX (`git show :<path>`), not from HEAD, and the difference is the whole
78
+ // usability of this rail. `npm pack` reads the WORKING TREE, so comparing it to HEAD makes the recipe red
79
+ // for any uncommitted edit — and `.portulan/dod.md` requires every recipe green BEFORE a commit, which a
80
+ // check that can only pass after one contradicts. The index is what is about to become the commit, so
81
+ // staged work compares clean and unstaged work is correctly still a difference. In CI the distinction
82
+ // vanishes: `actions/checkout` leaves the index matching HEAD, so this reads exactly as HEAD there.
83
+ /** The staged blob for a path, or null when the path is not tracked. */
84
+ function blobAt(root, rel) {
85
+ try {
86
+ return run("git", ["show", `:${rel}`], { cwd: root, stdio: ["ignore", "pipe", "ignore"] });
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ // Every path npm reports is checked for CONTAINMENT before it is used, by RESOLUTION rather than by
93
+ // pattern — the shape `cli/eval-bundle.mjs` was hardened to on #280, for the same reason: a pattern
94
+ // test answers a question about a string, and what matters is where the filesystem actually goes.
95
+ // `rel` arrives from `npm pack`'s JSON, so it is not this tool's to trust: an absolute path, a `..`
96
+ // escape or an embedded NUL would send both `git show` and the read outside the repository, and the
97
+ // rail would then compare the wrong bytes and report GREEN. Refused as could-not-run, never as a
98
+ // finding — a path this tool will not resolve supports no verdict about the package.
99
+ //
100
+ // Symlinks are refused for the same reason and separately from containment: `readFileSync` follows
101
+ // them, so a link whose target sits outside the tree reads bytes git never carried while `git show`
102
+ // reads the link itself. `lstat` is what distinguishes them, and `realpath` alone would not.
103
+ function containedPath(root, rel) {
104
+ if (rel.includes("\0")) throw new CannotRun(`npm reported a path containing NUL — refusing to resolve it`);
105
+ if (path.isAbsolute(rel)) throw new CannotRun(`npm reported an absolute path (${rel}); packed paths are repo-relative`);
106
+ const resolved = path.resolve(root, rel);
107
+ const base = path.resolve(root);
108
+ if (resolved !== base && !resolved.startsWith(base + path.sep)) {
109
+ throw new CannotRun(`npm reported a path that resolves outside the repository: ${rel}`);
110
+ }
111
+ let stat;
112
+ try {
113
+ stat = fs.lstatSync(resolved);
114
+ } catch (error) {
115
+ throw new CannotRun(`packed path ${rel} could not be stat'd: ${error.message.split("\n")[0]}`);
116
+ }
117
+ if (stat.isSymbolicLink()) throw new CannotRun(`packed path ${rel} is a symlink; its target is not what git carries`);
118
+ if (!stat.isFile()) throw new CannotRun(`packed path ${rel} is not a regular file`);
119
+ return resolved;
120
+ }
121
+
122
+ export function compare(root) {
123
+ try {
124
+ run("git", ["rev-parse", "--verify", "HEAD"], { cwd: root, stdio: ["ignore", "pipe", "ignore"] });
125
+ } catch {
126
+ throw new CannotRun("not a git repository, or HEAD does not resolve");
127
+ }
128
+ const packed = packedPaths(root);
129
+ const untracked = [];
130
+ const differing = [];
131
+ for (const rel of packed) {
132
+ // `package.json` is the one path npm may rewrite on pack — it normalises fields. Compared like
133
+ // every other file rather than exempted: the 2026-08-13 measurement asserted all 114 identical
134
+ // "package.json included, with no exemption", and an exemption added here would quietly retire
135
+ // that claim.
136
+ const abs = containedPath(root, rel);
137
+ const blob = blobAt(root, rel);
138
+ if (blob === null) untracked.push(rel);
139
+ // Read with `fs`, not by shelling out. `cat` would be an undeclared dependency this recipe's
140
+ // `requires` does not list, and it takes its argument as an OPTION when a filename begins with
141
+ // `-` — so a file named `-n` would silently be read as a flag rather than compared.
142
+ else if (!blob.equals(fs.readFileSync(abs))) differing.push(rel);
143
+ }
144
+ return { packed, untracked, differing };
145
+ }
146
+
147
+ function main(argv, stdout, stderr) {
148
+ const root = argv[2] ?? process.cwd();
149
+ let result;
150
+ try {
151
+ result = compare(root);
152
+ } catch (error) {
153
+ if (error instanceof CannotRun) {
154
+ stderr.write(`pack-identity: could not run — ${error.message}\n`);
155
+ return 2;
156
+ }
157
+ // An unexpected throw is COULD-NOT-RUN, never a finding. Rethrowing would exit non-contractually
158
+ // — node's own code, typically 1 — and the runner would read a crash in this rail as a verdict
159
+ // about the package's bytes. `1` is reserved for a finding; this repository has a commit by that
160
+ // name. The diagnostic names the rail so the exit is traceable to the thing that broke.
161
+ stderr.write(`pack-identity: could not run — unexpected ${error?.name ?? "error"}: ${String(error?.message ?? error).split("\n")[0]}\n`);
162
+ return 2;
163
+ }
164
+ const { packed, untracked, differing } = result;
165
+ if (untracked.length === 0 && differing.length === 0) {
166
+ stdout.write(`ok pack-identity — all ${packed.length} packed file(s) are byte-identical to their staged blob\n`);
167
+ return 0;
168
+ }
169
+ for (const rel of untracked) stderr.write(`pack-identity: ${rel} would ship but is NOT TRACKED — the package would carry a byte nobody reviewed\n`);
170
+ for (const rel of differing) stderr.write(`pack-identity: ${rel} differs from \`git show :${rel}\` — the package would not install the tree's bytes\n`);
171
+ stderr.write(`pack-identity: ${untracked.length + differing.length} of ${packed.length} packed file(s) failed; .portulan/identity.md's same-bytes claim does not hold\n`);
172
+ return 1;
173
+ }
174
+
175
+ // The same guard `pack-version.mjs` uses. A bare path comparison is FALSE when npm installs a bin as a
176
+ // symlink — node realpaths the main module for `import.meta.url` while `process.argv[1]` keeps the link.
177
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
178
+ process.exitCode = main(process.argv, process.stdout, process.stderr);
179
+ }
180
+ export { main };
package/cli/stop-gate.mjs CHANGED
@@ -237,7 +237,13 @@ function defaultRecipe() {
237
237
  * Found by review on the pull request.
238
238
  */
239
239
  function didWork() {
240
- const git = (args) => execFileSync("git", args, { cwd: REPO, encoding: "utf8", timeout: 10_000 });
240
+ // `maxBuffer` raised from node's 1 MiB default **for `git cherry`, which cannot be bounded the way
241
+ // the emptiness probe below can**: it must print a line per compared commit to be read at all,
242
+ // where the probe only has to answer yes-or-no and so is asked with `--max-count=1`. An overflow
243
+ // here throws into the outer catch and reads as *cannot tell* on precisely the branches that carry
244
+ // the most commits. This raise moves that cliff rather than removing it — every buffer is finite —
245
+ // which is why the probe is bounded instead of merely given more room.
246
+ const git = (args) => execFileSync("git", args, { cwd: REPO, encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024 * 1024 });
241
247
 
242
248
  // 1. Uncommitted changes. Portable, and the strongest signal there is.
243
249
  try {
@@ -256,10 +262,122 @@ function didWork() {
256
262
  // No upstream configured. Ordinary for a branch that has never been pushed — fall through.
257
263
  }
258
264
 
259
- // 3. Commits that are on no remote at all. Works with no upstream, and with no remote either —
260
- // in a repository with no remotes every commit qualifies, which is the correct reading there.
265
+ // 3. Commits whose PATCHES are on no remote — compared by patch-id, never by reachability.
266
+ //
267
+ // **This repository rebase-merges, and that makes reachability the wrong question** (#220). A
268
+ // rebase-merge rewrites commits, so a merged branch's originals appear on no remote; once the
269
+ // remote branch is deleted on merge they never will. `HEAD --not --remotes` therefore answers
270
+ // *yes, there is unrecorded work* **permanently**, for every commit of every merged-and-deleted
271
+ // branch — and any checkout left on such a branch demanded a handoff from a session that had
272
+ // done nothing. Measured 2026-08-10: the gate blocked twice over a handoff that was already on
273
+ // `main`, on the strength of four commits `git cherry` showed as fully upstream.
274
+ //
275
+ // The class is named in this repository for a different tool —
276
+ // `../.portulan/memory/a-branch-syncs-with-main-before-it-merges.md`, *"a rebase-merge leaves the
277
+ // branch tip an ancestor of nothing"* — and the sanctioned test there is `git cherry`:
278
+ // comparison by patch-id. This is that record applied at the site that asked the same wrong
279
+ // question.
261
280
  try {
262
- return git(["log", "--oneline", "HEAD", "--not", "--remotes"]).trim() !== "";
281
+ // The coarse reading first, because it is cheap and it BOUNDS the expensive one: nothing on
282
+ // no remote by reachability means nothing on no remote by patch either, since a rewritten
283
+ // commit is still a commit that reachability would have found.
284
+ //
285
+ // **`rev-list --max-count=1`, not `log --oneline`, and the difference is a fail-open.** This
286
+ // asks only whether the set is EMPTY, but `log --oneline` answers by printing one line per
287
+ // commit — and a long-lived checkout on a rebase-merged branch is exactly where that set gets
288
+ // large, which is the situation this whole signal exists for. Past whatever `maxBuffer` is in
289
+ // force, `execFileSync` throws `ENOBUFS` (measured against node's 1 MiB default, which is what
290
+ // this ran on when the defect was found), the outer catch below turns that into `return false`,
291
+ // and the handoff check disables itself on the repositories most likely to need it.
292
+ //
293
+ // **The raised `maxBuffer` above does not make this redundant, and the order matters.** That
294
+ // raise moves the cliff; it does not remove one, because every buffer is finite and the set
295
+ // this probe asks about has no bound. Bounding the QUESTION is what removes the failure: one
296
+ // hash cannot outgrow any buffer. The raise is defence in depth for `git cherry`, which must
297
+ // print a line per compared commit and so cannot be bounded the same way. Copilot, round 3;
298
+ // the wording corrected in the same round after the raise made the original rationale stale.
299
+ if (git(["rev-list", "--max-count=1", "HEAD", "--not", "--remotes"]).trim() === "") return false;
300
+
301
+ // No remote at all: every commit qualifies, which is the correct reading there and is what
302
+ // this signal has always said. Documented rather than degraded, so it reports nothing.
303
+ const remotes = git(["remote"]).trim();
304
+ if (remotes === "") return true;
305
+
306
+ // A base to compare patches against: the remote's own recorded default head, never a branch
307
+ // picked by name. `./init.mjs`'s `delete-a-remote-branch` reason settles that — *"a wrong base
308
+ // answers no-unique-work about a question nobody asked"* — and `main` is not a name this file
309
+ // may assume, for the same reason signal 2 above stopped hard-coding `origin/main`.
310
+ // **ONE ordering, used by both the base selection and the sentence that reports its failure.**
311
+ // `git remote` lists alphabetically, so a repository carrying `backup` beside `origin` offered
312
+ // `backup/HEAD` first — and this loop takes the first that resolves, which means comparing
313
+ // patch-ids against a non-canonical remote and reaching a wrong allow/block, not merely a
314
+ // clumsy message. `origin` is the conventional canonical remote and is preferred where it
315
+ // exists. Written as one list rather than two matching literals because the first version of
316
+ // this preferred `origin` in the DEGRADED SENTENCE only, leaving the verdict itself on the
317
+ // alphabetical remote — a fix that repaired the report of a defect and not the defect.
318
+ // Copilot, round 3.
319
+ let base = null;
320
+ const listed = remotes.split("\n").filter(Boolean);
321
+ const named = listed.includes("origin") ? ["origin", ...listed.filter((r) => r !== "origin")] : listed;
322
+ for (const remote of named) {
323
+ try {
324
+ base = git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${remote}/HEAD`]).trim() || null;
325
+ if (base) break;
326
+ } catch {
327
+ // This remote records no default head. Try the next one.
328
+ }
329
+ }
330
+
331
+ // **The refinement failed, so the coarse reading stands — and it stands as TRUE.**
332
+ //
333
+ // This is the fail-CLOSED direction and it is chosen rather than defaulted into. Commits on
334
+ // no remote is an established fact by the time we are here; only the attempt to excuse them
335
+ // as already-upstream could not be made. Reading that as *no work* would newly convert a
336
+ // case that blocks today into a pass — a relaxation, and this gate's own refusal message
337
+ // says relaxing a check is the change to scrutinise hardest. The asymmetry decides it: a
338
+ // wrong block is capped at MAX_BLOCKS and speaks, a wrong pass is unbounded and silent.
339
+ // `../.portulan/memory/verify-preconditions-fail-closed.md` is cited in this file's header
340
+ // for exactly this direction.
341
+ // The advice NAMES A COMMAND THE READER CAN RUN. The first draft always said
342
+ // `git cherry <remote>/HEAD HEAD` — but the commonest way into this branch is that
343
+ // `<remote>/HEAD` does not resolve, so the suggested check failed for the very reason the
344
+ // refinement did. Telling someone to re-run the thing that just broke is a diagnosis-shaped
345
+ // sentence carrying no diagnosis. Copilot, round 1.
346
+ const degraded = (why, check) => {
347
+ process.stderr.write(
348
+ `portulan stop-gate: could not compare by patch-id (${why}), so commits on no remote are ` +
349
+ "being read as work. If this branch was rebase-merged the handoff demand may be spurious — " +
350
+ `${check} and say so rather than working around the gate.\n`,
351
+ );
352
+ return true;
353
+ };
354
+ if (!base) {
355
+ // **The remotes are NAMED, because this runner already knows them** — it just tried every
356
+ // one. A `<remote>` placeholder in a multi-remote repository invites the reader to run the
357
+ // repair against whichever they think of first, which is how a diagnosis sends someone to
358
+ // the wrong ref. Copilot, round 3. `named` is already ordered `origin`-first above, so the
359
+ // remote this repairs is the same one the loop would have compared against — the two
360
+ // cannot disagree, which is why the ordering is computed once.
361
+ const which = named[0];
362
+ return degraded(
363
+ `no remote records a default head (tried \`${named.join("`, `")}\`)`,
364
+ `record one with \`git remote set-head ${which} -a\`, then check with ` +
365
+ `\`git cherry ${which}/HEAD HEAD\``,
366
+ );
367
+ }
368
+
369
+ // **Exit 0 AND zero `+` lines, both required.** `git cherry` against an unknown ref exits 128
370
+ // and prints NOTHING, so counting `+` alone reads a failed command as *nothing unmerged* —
371
+ // measured, and already recorded in `../.portulan/gate-map.md` and `./init.mjs`'s
372
+ // `delete-a-remote-branch` reason as a fail-open closed once before. The exit half is carried
373
+ // by `git()` itself, which throws on non-zero; the catch below is that half, not decoration.
374
+ let cherry;
375
+ try {
376
+ cherry = git(["cherry", base, "HEAD"]);
377
+ } catch {
378
+ return degraded(`\`git cherry ${base} HEAD\` could not run`, `check with \`git cherry ${base} HEAD\``);
379
+ }
380
+ return cherry.split("\n").some((line) => line.startsWith("+"));
263
381
  } catch {
264
382
  // Genuinely cannot tell. Say so rather than inventing an obligation OR silently dropping one:
265
383
  // a gate that disables itself without a word is the failure this whole runner exists against.
@@ -269,8 +387,7 @@ function didWork() {
269
387
  }
270
388
 
271
389
  /** Is there a handoff dated today? The doctrine's checkable form: existence and a date, never length. */
272
- function handoffToday() {
273
- const stamp = today();
390
+ function handoffToday(stamp) {
274
391
  try {
275
392
  return fs.readdirSync(path.join(WORKSPACE, "handoffs")).some((f) => f.startsWith(stamp) && f.endsWith(".md"));
276
393
  } catch {
@@ -278,6 +395,83 @@ function handoffToday() {
278
395
  }
279
396
  }
280
397
 
398
+ /**
399
+ * WHICH TREE this gate just answered about — the working copy and the branch it is on.
400
+ *
401
+ * `handoffToday()` reads the directory under `WORKSPACE`, which is derived from `PROJECT`, which is
402
+ * whatever the host told this hook or whatever the cwd happens to be. That is deliberate — this file
403
+ * is TOLD its root rather than deriving one, for the reason its header gives — but it means the gate
404
+ * can ask a question about one tree and answer it from another. Measured 2026-08-10: a session whose
405
+ * worktree had been removed had its cwd fall back to the origin repository, parked on an unrelated
406
+ * branch 84 commits behind, and was told *"no handoff dated 2026-08-10"* while five sat on `main`.
407
+ *
408
+ * **This names the tree rather than rescoping the gate to it, and that is a choice with a cost.**
409
+ * #220 offers rescoping as the alternative and it is the more thorough repair — but the tree a
410
+ * removed worktree wrote to no longer exists to be read, so rescoping cannot answer the very
411
+ * incident that produced the issue. What a reader needed, and did not get, was to see that the
412
+ * sentence was true about a tree they were not thinking of.
413
+ */
414
+ function treeIdentity() {
415
+ try {
416
+ const run = (args) => execFileSync("git", args, {
417
+ cwd: REPO, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"],
418
+ }).trim();
419
+ const branch = run(["rev-parse", "--abbrev-ref", "HEAD"]);
420
+ // **A DETACHED HEAD is the common case here, not the exotic one.** `--abbrev-ref` answers the
421
+ // literal string `HEAD` when no branch is checked out, and this repository routinely has several
422
+ // detached worktrees at once — measured: four, on the machine this was written on. Printing
423
+ // *"on `HEAD`"* would name a branch that does not exist, in the very sentence added so a reader
424
+ // could identify the tree. The commit is what identifies a detached one. Copilot, round 3.
425
+ if (branch === "HEAD") return `${REPO} (detached at \`${run(["rev-parse", "--short", "HEAD"])}\`)`;
426
+ return `${REPO} (on \`${branch}\`)`;
427
+ } catch {
428
+ return REPO;
429
+ }
430
+ }
431
+
432
+ /**
433
+ * A handoff dated today somewhere in this repository's history — every ref already on disk — or null.
434
+ *
435
+ * **No network, deliberately.** A Stop hook runs on every attempt to end a turn, and a fetch would
436
+ * put a remote round trip — and an offline host's timeout — inside the gate. So this reads refs that
437
+ * are already on disk — `--all` is every ref this repository carries, local branches included, not only
438
+ * remote-tracking ones. The sentence it feeds must not overclaim: *absent from this working tree* is
439
+ * always true when it fires; *present at `<ref>`* is claimed only where some ref on disk shows one.
440
+ */
441
+ function handoffInHistory(stamp) {
442
+ try {
443
+ // **Separators normalised to `/`, because this becomes a git PATHSPEC.** `path.relative` yields
444
+ // `\` on Windows and git would match nothing — and the failure is silent: this returns null,
445
+ // the refusal loses the half that tells a reader their handoff is already recorded, and nothing
446
+ // says why. The same spelling is used wherever this repository hands git a derived path
447
+ // (`./librarian.mjs`, `./compile.mjs`). Copilot, round 3.
448
+ const dir = path.relative(REPO, path.join(WORKSPACE, "handoffs")).split(path.sep).join("/");
449
+ // A workspace outside the repository is not a question git can answer about this history.
450
+ if (dir === "" || dir.startsWith("..") || path.isAbsolute(dir)) return null;
451
+ const git = (args) => execFileSync("git", args, { cwd: REPO, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] });
452
+ // `--all` is every ref already on disk, local and remote-tracking. `-1` because existence is
453
+ // the whole question — this is the doctrine's checkable form, not a census.
454
+ // **`:(glob)` magic, because a bare `*` is not reliably a glob.** With `GIT_NOGLOB_PATHSPECS`
455
+ // set, git reads `*` literally, this matches nothing, and the function returns null — silently
456
+ // reintroducing the exact "a handoff exists elsewhere and the gate does not say so" gap this
457
+ // arm exists to close. Measured on git 2.50.1: bare pattern matches 1 normally and **0** under
458
+ // `GIT_NOGLOB_PATHSPECS=1`; with `:(glob)` it matches 1 under both. _(The neighbouring lever
459
+ // `core.globPathspec=false` does NOT reproduce it — measured — so the magic is what makes this
460
+ // deterministic, not a config default.)_ Explicit pathspec magic is the same discipline
461
+ // `../.portulan/verify/plugin.sh` documents. Copilot, round 3.
462
+ const commit = git(["log", "--all", "-1", "--format=%H", "--", `:(glob)${dir}/${stamp}*`]).trim();
463
+ if (!commit) return null;
464
+ const refs = git(["branch", "--all", "--contains", commit, "--format=%(refname:short)"])
465
+ .split("\n").map((r) => r.trim()).filter(Boolean);
466
+ return { commit: commit.slice(0, 7), ref: refs[0] ?? null };
467
+ } catch {
468
+ // Cannot look. Say nothing rather than claim an absence — the refusal below is already
469
+ // correct without this sentence, and a false "nowhere in history either" would be worse
470
+ // than silence.
471
+ return null;
472
+ }
473
+ }
474
+
281
475
  /**
282
476
  * Everything this gate refuses a stop for, plus whether the governing recipe was observed GREEN.
283
477
  * The two are reported separately because the consecutive-cap reset keys off the recipe alone, while
@@ -332,14 +526,35 @@ function collectProblems() {
332
526
  }
333
527
  }
334
528
 
335
- const handoffPresent = handoffToday();
336
- if (didWork() && !handoffPresent) {
529
+ // **ONE date per Stop event, captured once and threaded through every use.** There were four
530
+ // independent `today()` calls on this path — the tree check, the history query, and both halves of
531
+ // the sentence. A stop spanning local midnight (or a host clock that jumps) could therefore CHECK
532
+ // one date and REPORT another, and worse, search history for a date nobody checked the tree for.
533
+ // `today()`'s own header records that this file has already produced one false red from a date
534
+ // disagreement; two dates inside a single verdict is the same class. Copilot, round 3.
535
+ const stamp = today();
536
+ const handoffPresent = handoffToday(stamp);
537
+ // **`!handoffPresent` first, and the order is load-bearing rather than stylistic.** `didWork()`
538
+ // shells out to git several times and can print the could-not-compare sentence; ordered the other
539
+ // way, a session that HAS written its handoff still paid that cost on every Stop event and could
540
+ // be handed a degradation warning about an obligation it does not owe. Copilot, round 1.
541
+ if (!handoffPresent && didWork()) {
542
+ // The tree is NAMED, because the sentence was once true about a tree the reader was not
543
+ // thinking of and read as a verdict about their session (#220).
544
+ const elsewhere = handoffInHistory(stamp);
545
+ const found = elsewhere
546
+ ? ` One dated ${stamp} does exist elsewhere in this repository's refs, at ` +
547
+ `${elsewhere.commit}${elsewhere.ref ? ` on \`${elsewhere.ref}\`` : ""} — so this working tree may not be ` +
548
+ "the tree that did the work. Check before writing a second one: a duplicate handoff reds `docs.sh`'s " +
549
+ "record check, which is the trap the bare sentence used to set."
550
+ : "";
337
551
  problems.push({
338
552
  reason: "handoff",
339
553
  text:
340
- `no handoff dated ${today()} in .portulan/handoffs/. Every session ends with a dated handoff — ` +
341
- "five lines is enough, absent is not. The Session log records what landed; the handoff records why, " +
342
- "and the why is the part the next session cannot reconstruct from the diff.",
554
+ `no handoff dated ${stamp} in ${path.join(WORKSPACE, "handoffs")}, read from ${treeIdentity()}. ` +
555
+ "Every session ends with a dated handoff — five lines is enough, absent is not. The Session log " +
556
+ "records what landed; the handoff records why, and the why is the part the next session cannot " +
557
+ `reconstruct from the diff.${found}`,
343
558
  });
344
559
  }
345
560
 
package/cli/upgrade.mjs CHANGED
@@ -8,9 +8,8 @@
8
8
  //
9
9
  // Not here. `spec/migrations/` is the contract — a directory of steps, each a module — because a
10
10
  // migration is part of the **Workspace Definition** rather than part of a tool, and `spec/` ships in
11
- // `package.json`'s `files` so it would reach an `npx` user if the package were published — publishing
12
- // is Gated and has not happened, so there is no such user today, and whether there ever is remains
13
- // open (#242). See `../.portulan/identity.md`. This file is the runner: it decides which
11
+ // `package.json`'s `files` so it reaches an `npx` user — the package was published 2026-08-18 at
12
+ // `0.1.0`, closing #242, so that user now exists. See `../.portulan/identity.md`. This file is the runner: it decides which
14
13
  // steps a workspace owes, applies them, and grades the result with the real validator.
15
14
  //
16
15
  // **Two kinds of step**, by the maintainer's ruling of 2026-08-12: a `version` step migrates a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleepy_panda_srl/portulan",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Portulan — an agentic-engineering framework whose product is its files. The CLI wraps the file-based mechanics for hosts beyond Claude Code.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",