@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 +140 -50
- package/cli/README.md +20 -12
- package/cli/init.mjs +3 -2
- package/cli/pack-identity.mjs +180 -0
- package/cli/stop-gate.mjs +226 -11
- package/cli/upgrade.mjs +2 -3
- package/package.json +1 -1
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
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
6
|
+
[](https://www.npmjs.com/package/@sleepy_panda_srl/portulan)
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](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
|
-
|
|
18
|
-
account of
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
38
|
+
### As a Claude Code plugin
|
|
30
39
|
|
|
31
|
-
This repository
|
|
32
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
`
|
|
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
|
-
##
|
|
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
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
59
|
-
|
|
|
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
|
-
|
|
|
65
|
-
| `.
|
|
66
|
-
|
|
|
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
|
-
##
|
|
153
|
+
## Documentation
|
|
69
154
|
|
|
70
|
-
- [`docs/vision.md`](docs/vision.md) — the constitution: what Portulan is, the
|
|
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
|
|
76
|
-
cloning and forking are what the licence allows; bugs, proposals and feedback
|
|
77
|
-
[issue forms](https://github.com/sleepy-panda-srl/portulan/issues/new/choose);
|
|
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
|
-
|
|
81
|
-
|
|
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`
|
|
12
|
-
mention below.**
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 **
|
|
42
|
-
`librarian`, `control-chars`, `discover`, `rule-carriers`, `pack-version
|
|
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 —
|
|
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:
|
|
990
|
-
* answer and the recipe's expected first state there is **exit 2**.
|
|
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
|
-
|
|
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
|
|
260
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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 ${
|
|
341
|
-
"five lines is enough, absent is not. The Session log
|
|
342
|
-
"and the why is the part the next session cannot
|
|
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
|
|
12
|
-
//
|
|
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.
|
|
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",
|