projectstore-claude 0.28.0-rc.3 โ†’ 0.28.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.
@@ -12,7 +12,7 @@
12
12
  "name": "projectstore",
13
13
  "displayName": "projectstore",
14
14
  "description": "๐Ÿ“š Your agent runs the project through a verified loop: task โ†’ artifact (ADR ยท spec ยท epic ยท story) โ†’ adversarial critic โ†’ backlog โ†’ planner โ†’ reviewer โ†’ done. Plain markdown in an Obsidian-friendly vault, every write approved by you โ€” and any model can pick the project up tomorrow.",
15
- "version": "0.28.0-rc.3",
15
+ "version": "0.28.1",
16
16
  "author": {
17
17
  "name": "Evgenii Konev",
18
18
  "email": "ekonev@smartandpoint.com",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "projectstore",
3
3
  "displayName": "projectstore",
4
- "version": "0.28.0-rc.3",
4
+ "version": "0.28.1",
5
5
  "description": "Your agent runs the project through a verified loop: task โ†’ artifact (ADR / spec / epic / story) โ†’ adversarial critic โ†’ backlog โ†’ planner โ†’ reviewer โ†’ done. Plain markdown in git โ€” any model can pick the project up tomorrow.",
6
6
  "author": {
7
7
  "name": "Evgenii Konev @ SmartAndPoint",
@@ -4,7 +4,7 @@
4
4
 
5
5
  [![release](https://img.shields.io/github/v/release/SmartAndPoint/ProjectStore?label=release)](https://github.com/SmartAndPoint/ProjectStore/releases) [![license](https://img.shields.io/github/license/SmartAndPoint/ProjectStore?label=license)](./LICENSE) [![Star on GitHub](https://img.shields.io/badge/%E2%AD%90-star_us-yellow?logo=github)](https://github.com/SmartAndPoint/ProjectStore/stargazers)
6
6
 
7
- A project workflow plugin for [Claude Code](https://claude.com/claude-code) and [OpenAI Codex](https://developers.openai.com/codex/) (experimental).
7
+ A project workflow plugin for [Claude Code](https://claude.com/claude-code) and [OpenAI Codex](https://developers.openai.com/codex/) (experimental). Both are released together at one version, and each installs with one command.
8
8
 
9
9
  ---
10
10
 
@@ -66,6 +66,14 @@ Open Claude Code in your project and say:
66
66
 
67
67
  That's the whole setup. Claude adds the marketplace, installs the plugin, and walks you through binding a vault, scaffolding it and wiring the status line โ€” every step previewed, nothing written without your Yes.
68
68
 
69
+ **On Codex**, run one command from a terminal in your project:
70
+
71
+ ```
72
+ npx projectstore-codex install --project "$PWD"
73
+ ```
74
+
75
+ Then restart Codex, approve the ProjectStore hooks when it asks, and run `$projectstore-bind ~/Documents/my-project-vault`. Codex support is newer and still labelled experimental: [`docs/harnesses.md`](./docs/harnesses.md#codex) says what has been verified and what has not. A project can be worked from both agents: they share the binding in `.projectstore/`, and each keeps its own overlay and session state beside it ([running both over one project](./docs/harnesses.md#running-both-over-one-project)).
76
+
69
77
  <details>
70
78
  <summary>Prefer to type it yourself?</summary>
71
79
 
@@ -88,28 +96,26 @@ npx projectstore-claude install --project "$PWD"
88
96
 
89
97
  The same tree is published to npm as [`projectstore`](https://www.npmjs.com/package/projectstore) โ€” one source package carrying every harness's manifest โ€” and `projectstore-claude` is its Claude Code shell: the core pinned at the same version and bundled inside, the harness fixed, so the one command has the same shape on every harness. It registers the plugin with Claude Code: it writes a small local marketplace of its own under your Claude home, then drives `claude plugin marketplace add` / `plugin install` **at local scope**, so the registration lands in this checkout's `.claude/settings.local.json` and nowhere else. Every host command is printed before it runs; naming the harness is the confirmation. Restart Claude Code afterwards. A git-marketplace copy already enabled for the checkout is silenced there (not globally) so the plugin does not load twice; `uninstall` turns it back on. Pin or upgrade with `npx projectstore-claude@<version> upgrade --project "$PWD"` โ€” the version you name is the version you run. The core's low-level form, `npx projectstore <verb> --harness claude-code โ€ฆ`, is exactly what the shell runs. bun works the same on the packed bin.
90
98
 
91
- **Codex uses its own rendered plugin root and the same one-command shape.**
92
- Codex support is **experimental** (see [`docs/harnesses.md`](./docs/harnesses.md)
93
- for what has been measured and what has not), and its shell is not published
94
- yet. From this checkout, exercise the exact npx path against the built tarball:
99
+ **Codex has its own shell with the same one-command shape:**
100
+ `npx projectstore-codex install --project "$PWD"`. It carries a canonical
101
+ portable manifest, namespaced workflow and role skills, lifecycle hooks, and
102
+ the exact bundled core. It stages a stable marketplace under `CODEX_HOME`,
103
+ drives `codex plugin marketplace add` and `codex plugin add`, then reads the
104
+ installation back and verifies its version and payload digest. Restart Codex,
105
+ approve the hooks, and run `$projectstore-bind <vault-path>`. For later
106
+ releases, `npx projectstore-codex@<version> upgrade --project "$PWD"`. Because
107
+ Codex's plugin registry is user-global, ordinary uninstall removes only the
108
+ project's agents block; `uninstall --global` is the explicit machine-wide
109
+ removal. Codex is **experimental** until a live session has exercised every
110
+ surface it installs; [`docs/harnesses.md`](./docs/harnesses.md) says what has
111
+ been measured and what has not. From a checkout, the same npx path runs against
112
+ a built tarball:
95
113
 
96
114
  ```sh
97
115
  npm run shells:build -- --only projectstore-codex --dev --out dist
98
116
  npx --package "./dist/projectstore-codex-$(node -p 'require("./package.json").version').tgz" projectstore-codex install --project "$PWD"
99
117
  ```
100
118
 
101
- The Codex shell carries a canonical portable manifest, namespaced workflow and
102
- role skills, lifecycle hooks, and the exact bundled core. It stages a stable
103
- marketplace under `CODEX_HOME`, drives `codex plugin marketplace add` and
104
- `codex plugin add`, then reads the installation back and verifies its version
105
- and payload digest. Restart Codex, approve the hooks, and run
106
- `$projectstore-bind <vault-path>`. After the first explicit npm publication,
107
- the shorter command is
108
- `npx projectstore-codex@<version> install --project "$PWD"`; use its `upgrade`
109
- verb for later releases. Because Codex's
110
- plugin registry is user-global, ordinary uninstall removes only the project's
111
- agents block; `uninstall --global` is the explicit machine-wide removal.
112
-
113
119
  The package also carries a `bin`. Without a session โ€” in CI, or in a shell โ€” the same core answers token-free, with a `--json` envelope on every verb:
114
120
 
115
121
  ```
@@ -141,7 +147,7 @@ npx projectstore bind ~/vaults/my-project
141
147
  npx projectstore init ~/vaults/new-project --language ru
142
148
  ```
143
149
 
144
- `projectstore-claude`, `projectstore-codex` and `projectstore-opencode` are this package's per-harness shells โ€” the core pinned and bundled, the harness fixed. Codex's shell is experimental and stays private until a live run has exercised every surface it installs, from an installed release ([`docs/harnesses.md`](./docs/harnesses.md)). The opencode shell publishes after its plugin root is rendered. The other `projectstore-*` names are reserved placeholders pointing back here. One source package, one version, N tarballs.
150
+ `projectstore-claude`, `projectstore-codex` and `projectstore-opencode` are this package's per-harness shells โ€” the core pinned and bundled, the harness fixed. The Claude Code and Codex shells are published at the core's version; Codex stays labelled experimental until a live run has exercised every surface it installs ([`docs/harnesses.md`](./docs/harnesses.md)). The opencode shell publishes after its plugin root is rendered. The other `projectstore-*` names are reserved placeholders pointing back here. One source package, one version, N tarballs.
145
151
  </details>
146
152
 
147
153
  ## Upgrading
@@ -168,9 +174,14 @@ bound before 0.28. What an existing project sees afterwards, and why:
168
174
  `npx projectstore-claude upgrade` on a git-marketplace install does more: it
169
175
  also registers the plugin from npm for this checkout and turns the
170
176
  git-marketplace copy off here, so `/plugin update` stops reaching the
171
- checkout. If that already happened,
177
+ checkout. The 0.28.0-rc.1 and rc.2 startup lines named that shell form
178
+ (`npx projectstore-claude@<version> upgrade`, without `--no-register`), so on
179
+ those, update first and run what the new startup line names. If it already
180
+ happened,
172
181
  `npx projectstore-claude@<version> uninstall --surface plugin --project "$PWD"`
173
- turns the git-marketplace copy back on. Restart, then run
182
+ (0.28.0 or later) turns the git-marketplace copy back on. That copy must be 0.28 or later โ€” an
183
+ older one reads a moved project as unbound โ€” so update it first if it is not.
184
+ Restart, then run
174
185
  `/projectstore:doctor --fix` in the new session, which re-stamps the status
175
186
  line against that copy.
176
187
  - **The status line keeps rendering.** A launcher written by an earlier version
@@ -197,6 +208,15 @@ bound before 0.28. What an existing project sees afterwards, and why:
197
208
  (`projectstore-decision-detector`, `projectstore-peer-reviewer`,
198
209
  `projectstore-story-completion`, `projectstore-vault-communication`). Nothing
199
210
  in a project names them; only a skill listing shows the new names.
211
+ - **Rolling back** to 0.27.x before the move: everything keeps working, since
212
+ 0.27.x still reads the old layout. It rewrites the status-line launcher in its
213
+ own form, as it always did; coming forward again, the startup line names the
214
+ move once more, and the move re-stamps the launcher. If a session already
215
+ re-stamped the status line or re-registered the agents block before the move
216
+ (rc.2's `/projectstore:doctor --fix` did both), 0.27.x reports a foreign
217
+ status line and a v4 block and leaves both alone โ€” unless you run its
218
+ `/projectstore:agents register`, which rewrites the block; the status line
219
+ still renders, and the move settles both.
200
220
  - **Rolling back** to 0.27.x after the move: 0.27.x looks for its binding under
201
221
  `.claude/`, finds none and offers `bind`. Do not accept. A re-bind writes
202
222
  `.claude/projectstore.json` again, and 0.28's `install` and `upgrade` refuse
@@ -28,7 +28,13 @@ subcommand (`.projectstore/projectstore.json`; else point to `/projectstore:bind
28
28
  prose byte-identical.
29
29
  3. A non-zero exit is a refusal โ€” a duplicated or unclosed block, a missing
30
30
  template โ€” relay it and stop. Never write the block with the Write or Edit
31
- tool: the verb is the only writer (install spec, contract 6).
31
+ tool: the verb is the only writer (install spec, contract 6). One exception,
32
+ read from the output, not assumed: when it shows the block applied and the
33
+ `layout` item skipped as deferred to a terminal outside the session, the
34
+ exit 1 is that deferral. The block is registered โ€” say so, and for the move
35
+ relay the command the startup line or `/projectstore:doctor`'s
36
+ `layout-legacy` finding names; never compose one. Any other non-zero exit
37
+ is a refusal.
32
38
 
33
39
  ## `unregister` โ€” remove what register added
34
40
 
@@ -25,6 +25,8 @@ You are running projectstore diagnostics (ADR-005: umbrella doctor).
25
25
  is in the report, it goes first and its command is the one repair** for a
26
26
  stale-launcher `surface`, a v3 `agents-block` and `agents-in-binding` as
27
27
  well: relay it and run nothing in-session for those (see its bullet below).
28
+ The report already shows the v3 block, and a block Claude Code cannot see,
29
+ as info that points at the move.
28
30
  - `worktree-unbound` โ†’ this checkout is a git worktree of a bound one. Offer
29
31
  `/projectstore:bind --inherit`, and say what it does: copies the parent's
30
32
  binding, leaves the vault shared and unchanged, carries no session state.
@@ -35,7 +37,10 @@ You are running projectstore diagnostics (ADR-005: umbrella doctor).
35
37
  - `agents-block` duplicate or stale โ†’ show the finding, then (after approval)
36
38
  run `node "${CLAUDE_PLUGIN_ROOT}/bin/projectstore.mjs" install --harness claude-code --surface agents_block --project "${CLAUDE_PROJECT_DIR}"`
37
39
  and print its output: it removes the copy in the non-preferred file and
38
- keeps the preferred one current. Never Edit or Write the block yourself โ€”
40
+ keeps the preferred one current. A block Claude Code cannot see โ€” in
41
+ `AGENTS.md`, with no `@AGENTS.md` line in `CLAUDE.md` โ€” is the same repair:
42
+ the verb adds the import. For another harness the finding names its own
43
+ `--harness`; relay that command. Never Edit or Write the block yourself โ€”
39
44
  the verb is its only writer (install spec, contract 6).
40
45
  - `statusline` issues โ†’ offer running `/projectstore:statusline on|off`,
41
46
  which installs or removes the entry and the launcher behind a preview
@@ -74,8 +79,10 @@ You are running projectstore diagnostics (ADR-005: umbrella doctor).
74
79
  git-marketplace install or a checkout, the `projectstore-claude` shell for
75
80
  the npm registration. Relay the finding's command verbatim and never
76
81
  substitute the other form โ€” the shell, run for a git-marketplace install,
77
- would also move this checkout to the npm channel. Never move the files
78
- yourself. While this finding is in the report, its command is also the one
82
+ would also move this checkout to the npm channel. When the finding carries
83
+ advice instead of a command โ€” the installed copy predates the move, or
84
+ predates `--no-register` โ€” relay the advice and never compose a command
85
+ yourself. Never move the files yourself. While this finding is in the report, its command is also the one
79
86
  repair for a stale-launcher `surface`, a v3 `agents-block` and
80
87
  `agents-in-binding`: do not run the in-session `install` or `upgrade` for
81
88
  those โ€” the deferred move makes that run stop part-way.
@@ -23,8 +23,10 @@ and the measurements in the manifest came from that run.
23
23
  **experimental** means `verified` is `null`. Every field that was not measured
24
24
  is absent or `null` rather than guessed. It is enough to run on; it is not enough
25
25
  to promise. An experimental harness is never the source layout. A generated
26
- adapter may exist while it is experimental, but its distribution shell stays
27
- private until the complete built artifact passes the live gate.
26
+ adapter may exist while it is experimental. Its distribution shell may
27
+ publish before the live run that sets `verified`, but only by the maintainer's
28
+ decision (Codex's does, from 0.28.1); the label stays experimental until that
29
+ run.
28
30
 
29
31
  The label is not prose. It is derived from the manifest, and
30
32
  `tests/portability.test.mjs` fails if this table and `verified` disagree.
@@ -45,38 +47,50 @@ model names and those are harness-specific โ€” is
45
47
 
46
48
  Experimental, measured on `codex-cli 0.153.4`. The initial spike captured 759
47
49
  hook firings. The 2026-09-30 gate then built the npm shell from a packed core,
48
- passed the Codex plugin validator, installed and upgraded it through an isolated
50
+ passed Codex's plugin validator, installed and upgraded it through an isolated
49
51
  `CODEX_HOME`, verified the materialised cache by version and digest, and loaded
50
- `$projectstore-status` in a fresh Codex session.
52
+ the `projectstore-status` skill in a fresh Codex session. The validator (the
53
+ plugin-creator skill's `validate_plugin.py`) reads only
54
+ `.codex-plugin/plugin.json`. The canonical root `plugin.json` was validated
55
+ separately on 2026-10-04, against the schema it declares (Agent Plugins 1.0.0).
51
56
 
52
57
  That run exercised one skill, not every installed surface, so `verified` stays
53
58
  `null`. The hooks are the reason it matters. On 2026-10-03 the first real
54
- install's cached hooks could not load the vault in any session. They were run by
55
- hand through `zsh -lc`: that machine's default shell, started the way Codex's
56
- command runner starts a hook on its main branch (`<default shell> -lc`). That
57
- 0.153.4 does the same is not confirmed. In the first session
58
- the failure sat in the model's context under the welcome, which was all the user
59
- saw. The core had taken the shell's root for its own. That is fixed, and the
60
- suite now runs every rendered hook from the built shell. A live Codex session
61
- firing them from an installed release is still owed.
62
-
63
- The shell is not published. Build it and install the tarball through npx from
64
- this checkout:
59
+ install's cached hooks could not load the vault in any session. They were run
60
+ by hand through `zsh -lc`; the failure did not depend on the shell form. In the
61
+ first session the failure sat in the model's context under the welcome, which
62
+ was all the user saw. The core had taken the shell's root for its own. That is
63
+ fixed, and the suite now runs every rendered hook from the built shell. A live
64
+ Codex session firing them from an installed release is still owed.
65
+
66
+ How 0.153.4 starts a hook was read from its source, not measured: under the
67
+ session's shell as `<shell> -c`, with the environment the Codex process had when
68
+ it built the session's hooks. `$SHELL -lc` (or `/bin/sh -lc`) is the fallback
69
+ when the hooks are built without exactly one ready local environment. So a hook
70
+ finds `node` on the Codex process's own `PATH`, plus whatever `.zshenv` adds:
71
+ zsh reads `.zshenv` for a `-c` command, not `.zprofile` or `.zshrc`, so a Codex
72
+ not started from a terminal can miss a `node` that only `.zprofile` puts on
73
+ `PATH`.
74
+
75
+ The shell is published as `projectstore-codex` from 0.28.1, ahead of that live
76
+ run, by the maintainer's decision:
77
+
78
+ ```sh
79
+ npx projectstore-codex install --project "$PWD"
80
+ ```
81
+
82
+ For later releases, `npx projectstore-codex@<version> upgrade --project "$PWD"`.
83
+ Installation is user-global because Codex stores marketplace registrations and
84
+ plugin caches in `CODEX_HOME`; the agents block in `AGENTS.md` remains
85
+ project-local. Ordinary uninstall leaves the global plugin in place; add
86
+ `--global` only when you mean to remove it for every project. From this
87
+ checkout, the same flow runs against a built tarball:
65
88
 
66
89
  ```sh
67
90
  npm run shells:build -- --only projectstore-codex --dev --out dist
68
91
  npx --package "./dist/projectstore-codex-$(node -p 'require("./package.json").version').tgz" projectstore-codex install --project "$PWD"
69
92
  ```
70
93
 
71
- The shell package is release-gated until its first npm publication. From this
72
- repository, the same flow is exercised against the packed tarball. Installation
73
- is user-global because Codex stores marketplace registrations and plugin caches
74
- in `CODEX_HOME`; the agents block in `AGENTS.md` remains project-local. After
75
- the first explicit npm publication, the shorter registry form is
76
- `npx projectstore-codex@<version> install --project "$PWD"` (or `upgrade`). Ordinary
77
- uninstall leaves the global plugin in place; add `--global` only when you mean
78
- to remove it for every project.
79
-
80
94
  What is known, and how:
81
95
 
82
96
  - **Hooks are rendered; their live firing from an installed release is not
@@ -472,7 +472,7 @@
472
472
  "",
473
473
  "Without it, you would run `/plugin marketplace update SmartAndPoint` manually. See https://github.com/SmartAndPoint/ProjectStore#upgrading for details.",
474
474
  "",
475
- "**Upgrading from 0.27.x to 0.28**: restart after the update, then move the project's files to `.projectstore/` with the one command the first session names, run from a terminal outside the session: for a git-marketplace install, the installed copy's own `bin/projectstore.mjs upgrade --harness claude-code --no-register --project <dir>`, which leaves the registration as it is; for the npm registration, `npx projectstore-claude@<version> upgrade --no-register --project <dir>`. The same run re-stamps the status-line launcher, and until then every reader falls back to the old paths. `status` and `search` answer from artifact facts; `doctor` gains surface, version-drift and mcp lines and exits 1 on findings; the plugin registers an MCP server the host may ask you to approve once. After the move, 0.27.x reads the project as unbound."
475
+ "**Upgrading from 0.27.x to 0.28**: restart after the update, then move the project's files to `.projectstore/` with the one command the first session names, run from a terminal outside the session: for a git-marketplace install, the installed copy's own `bin/projectstore.mjs upgrade --harness claude-code --no-register --project <dir>`, which leaves the registration as it is; for the npm registration, `npx projectstore-claude@<version> upgrade --no-register --project <dir>`. The same run re-stamps the status-line launcher, and until then every reader falls back to the old paths. `status` and `search` answer from artifact facts; `doctor` gains surface, version-drift and mcp lines and exits 1 on findings; the plugin registers an MCP server the host may ask you to approve once. On 0.28.0-rc.1 or rc.2, update first: their startup line named the shell form without `--no-register` (`npx projectstore-claude@<version> upgrade`), which also switches a git-marketplace install to npm for the checkout (the README's Upgrading section has the way back). Before the move, a rollback to 0.27.x keeps working and the move is the way forward again; after the move, 0.27.x reads the project as unbound."
476
476
  ],
477
477
  "agent_translation": {
478
478
  "carries_model": true,
@@ -1,5 +1,5 @@
1
1
  {
2
- "_comment": "Harness capability manifest โ€” Codex. The committed adapters/codex tree renders the source commands, roles, passive skills and hooks into Codex vocabulary. Measurements began with 759 captured hook firings on 2026-09-07/08; the complete built shell, isolated marketplace install, upgrade, validator and fresh-session skill load passed on codex-cli 0.153.4 on 2026-09-30.",
2
+ "_comment": "Harness capability manifest โ€” Codex. The committed adapters/codex tree renders the source commands, roles, passive skills and hooks into Codex vocabulary. Measurements began with 759 captured hook firings on 2026-09-07/08; the complete built shell, isolated marketplace install, upgrade, validator and fresh-session skill load passed on codex-cli 0.153.4 on 2026-09-30. The validator reads only .codex-plugin/plugin.json; the canonical root plugin.json was validated separately on 2026-10-04 against the schema it declares (Agent Plugins 1.0.0).",
3
3
  "id": "codex",
4
4
  "display_name": "Codex",
5
5
  "emit": true,
@@ -7,7 +7,7 @@
7
7
  "output_dir": "adapters/codex",
8
8
  "docs": "https://developers.openai.com/codex/",
9
9
  "verified": null,
10
- "verified_reason": "Null, restored 2026-10-03. docs/harnesses.md calls a harness supported only when every installed surface has been exercised end to end, and contract 16 of the generation spec (Generated harness surfaces: manifests, the generator and the three invariants) derives that label from this field. The 2026-09-30 run (session 01a0f079-7ecd-7331-95a9-2471def3cb22) exercised one surface: a fresh `codex exec` session loaded projectstore-status from the materialised cache. The hooks were not exercised, and the first real install showed why that matters. Its cached hooks, run by hand through `zsh -lc` (that machine's default shell, started as Codex's runner on its main branch starts a hook, `<default shell> -lc`; unconfirmed on 0.153.4), could not load the vault in any session; in the first, the failure sat beneath the welcome. The core had taken the shell's root for its own. That is fixed and pinned by a test that runs every rendered hook from the built shell. This block is set by a live run that exercises every installed surface: the hooks once registration has settled, including PreCompact, and the skill flows.",
10
+ "verified_reason": "Null, restored 2026-10-03. docs/harnesses.md calls a harness supported only when every installed surface has been exercised end to end, and contract 16 of the generation spec (Generated harness surfaces: manifests, the generator and the three invariants) derives that label from this field. The 2026-09-30 run (session 01a0f079-7ecd-7331-95a9-2471def3cb22) exercised one surface: a fresh `codex exec` session loaded projectstore-status from the materialised cache. The hooks were not exercised, and the first real install showed why that matters. Its cached hooks, run by hand through `zsh -lc` (a form the failure did not depend on: read from source, 0.153.4 runs a hook as `<session shell> -c` with the Codex process's environment, and `$SHELL -lc` only as a fallback), could not load the vault in any session; in the first, the failure sat beneath the welcome. The core had taken the shell's root for its own. That is fixed and pinned by a test that runs every rendered hook from the built shell. This block is set by a live run that exercises every installed surface: the hooks once registration has settled, including PreCompact, and the skill flows. The shell is published from 0.28.1, ahead of that run, by the maintainer's decision of 2026-10-04; publication does not set this block.",
11
11
  "runtime": {
12
12
  "detect_env": [
13
13
  "PLUGIN_ROOT",
@@ -290,7 +290,8 @@
290
290
  "rewrites": [],
291
291
  "install": {
292
292
  "mechanism": "the package's portable plugin root, registered through its distribution shell and Codex's own marketplace CLI",
293
- "shell_reason": "The built `projectstore-codex` shell carries the rendered Codex skills and hooks plus an exact bundled core, fixes --harness codex, stages a stable marketplace under CODEX_HOME, and asks Codex itself to install it. The `shell` key is deliberately absent until its first npm publication, so generated guidance cannot send users to the reserved placeholder package.",
293
+ "shell": "projectstore-codex",
294
+ "shell_reason": "The built `projectstore-codex` shell carries the rendered Codex skills and hooks plus an exact bundled core, fixes --harness codex, stages a stable marketplace under CODEX_HOME, and asks Codex itself to install it. It is published from 0.28.1, by the maintainer's decision of 2026-10-04. Until then this key was absent, so generated guidance could not send users to the reserved 0.0.1 placeholder; the core alone is not Codex's plugin root and cannot register it.",
294
295
  "why_not_scripted": "The shell does not edit Codex's registry or cache. It atomically stages the plugin source, previews the host commands, then runs `codex plugin marketplace add` and `codex plugin add`; a read-back verifies the installed version and payload digest. The global plugin survives ordinary project uninstall; `uninstall --global` is the explicit destructive form.",
295
296
  "steps": [
296
297
  "npx projectstore-codex install --project \"$PWD\"",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore",
3
- "version": "0.28.0-rc.3",
3
+ "version": "0.28.1",
4
4
  "description": "Your agent runs the project through a verified loop: task โ†’ artifact (ADR / spec / epic / story) โ†’ adversarial critic โ†’ backlog โ†’ planner โ†’ reviewer โ†’ done. Plain markdown in git โ€” any model can pick the project up tomorrow.",
5
5
  "keywords": [
6
6
  "project-management",
@@ -60,6 +60,7 @@ import {
60
60
  lastVaultActivityMs,
61
61
  ENTRY_IGNORE,
62
62
  AGENTS_BLOCK_OPEN_SRC,
63
+ AGENTS_BLOCK_CLOSE,
63
64
  agentsBlockVersion,
64
65
  findAgentsBlock,
65
66
  statusLineLauncherPath,
@@ -75,8 +76,11 @@ import {
75
76
  hostSettingsPath,
76
77
  readOverlayAt, layoutRoster,
77
78
  installChannel,
79
+ cmpPrecedence,
80
+ cmpVersion,
81
+ blockVisibleTo,
78
82
  } from "./lib.mjs";
79
- import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness, detectHarnesses, configPath as harnessConfigPath, packageCommand } from "./harness.mjs";
83
+ import { agentOverrides, childEnv, sourceHarness, runtimeEnvNames, loadHarness, detectHarnesses, identifiedHarnessId, configPath as harnessConfigPath, packageCommand } from "./harness.mjs";
80
84
 
81
85
  // A remedy used to interpolate the surface's harness variable here. It cannot:
82
86
  // measured 2026-09-06, NO harness gives its Bash tool that variable, and a
@@ -387,9 +391,14 @@ export function checkPendingUpgrade(proj, home = homedir(), root = pluginRoot())
387
391
  relative(proj, lp))];
388
392
  }
389
393
 
390
- export function checkAgentsBlock(proj) {
394
+ // The two findings the layout move itself repairs carry what they say without
395
+ // their remedy, so a pending move can name itself instead (foldIntoMove).
396
+ const moveRepairs = (f, fact) => ({ ...f, byMove: fact });
397
+
398
+ export function checkAgentsBlock(proj, { env = process.env, root = pluginRoot() } = {}) {
391
399
  const out = [];
392
400
  const AGENT_BLOCK_VERSION = agentsBlockVersion();
401
+ const texts = {};
393
402
  // One parser for every reader (findAgentsBlock): its count is the loose one,
394
403
  // so a good block plus a re-wrapped marker in one file is "more than once"
395
404
  // here exactly as install and uninstall see it (both refuse), never a quiet
@@ -397,6 +406,7 @@ export function checkAgentsBlock(proj) {
397
406
  // registered" is the reading that makes install append a second block.
398
407
  let blocks = 0;
399
408
  let wrappedFiles = 0;
409
+ let unclosedFiles = 0;
400
410
  const perFile = {};
401
411
  const staleVersions = [];
402
412
  for (const name of ["CLAUDE.md", "AGENTS.md"]) {
@@ -404,6 +414,7 @@ export function checkAgentsBlock(proj) {
404
414
  if (!existsSync(p)) continue;
405
415
  let text;
406
416
  try { text = readFileSync(p, "utf8"); } catch { continue; }
417
+ texts[name] = text;
407
418
  const f = findAgentsBlock(text);
408
419
  if (!f) continue;
409
420
  perFile[name] = f.count;
@@ -414,6 +425,13 @@ export function checkAgentsBlock(proj) {
414
425
  `${name}:${f.line}: the projectstore:agents open marker does not close on its own line โ€” put \`-->\` back on the marker's line, then run /projectstore:agents register (install and uninstall refuse until it does).`, name));
415
426
  continue;
416
427
  }
428
+ if (f.unclosed) {
429
+ // Named here as the wrapped marker is, so the startup count carries it:
430
+ // the agents-block plan refuses it, and the layout move with it.
431
+ unclosedFiles++;
432
+ out.push(finding("install", "issue", "agents-block",
433
+ `${name}: the projectstore:agents block opens and never closes โ€” close it with \`${AGENTS_BLOCK_CLOSE}\` or delete the half block, then run /projectstore:agents register (install and uninstall refuse until then).`, name));
434
+ }
417
435
  for (const m of text.matchAll(AGENT_BLOCK_MARKER)) {
418
436
  const v = parseInt(m[1], 10);
419
437
  if (v !== AGENT_BLOCK_VERSION) staleVersions.push({ file: name, v });
@@ -427,25 +445,75 @@ export function checkAgentsBlock(proj) {
427
445
  // One block in each file is a state install resolves (it keeps the
428
446
  // preferred file's); two in one file is not, and stays an issue โ€” and a
429
447
  // wrapped marker anywhere means install refuses, so the "both files"
430
- // advice is withheld while one is wrapped.
448
+ // advice is withheld while one is wrapped or never closes.
431
449
  const twiceInOne = Object.entries(perFile).find(([, n]) => n > 1);
432
450
  if (twiceInOne) {
433
451
  out.push(finding("install", "issue", "agents-block",
434
452
  `${twiceInOne[0]} carries the projectstore:agents block ${twiceInOne[1]} times โ€” keep exactly one; install refuses until it does.`, twiceInOne[0]));
435
- } else if (wrappedFiles) {
453
+ } else if (wrappedFiles || unclosedFiles) {
436
454
  // already named above, file by file
437
455
  } else {
438
456
  out.push(finding("install", "warn", "agents-block",
439
457
  `The projectstore:agents block is in both CLAUDE.md and AGENTS.md โ€” run /projectstore:agents register: install keeps the one in ${(sourceHarness()?.surfaces?.agents_block?.files || ["AGENTS.md"])[0]} and removes the other.`));
440
458
  }
441
459
  }
460
+ // A state the agents-block plan refuses โ€” a wrapped marker, a block that
461
+ // never closes, a block twice in one file โ€” refuses the move with it, so
462
+ // nothing here is the move's to repair while one stands.
463
+ const refuses = wrappedFiles > 0 || unclosedFiles > 0 || Object.values(perFile).some((n) => n > 1);
442
464
  for (const s of staleVersions) {
443
- out.push(finding("install", "issue", "agents-block",
444
- `Agents block in ${s.file} is v${s.v}, expected v${AGENT_BLOCK_VERSION} โ€” re-run /projectstore:agents register.`, s.file));
465
+ const fact = `Agents block in ${s.file} is v${s.v}, expected v${AGENT_BLOCK_VERSION}`;
466
+ const f = finding("install", "issue", "agents-block", `${fact} โ€” re-run /projectstore:agents register.`, s.file);
467
+ out.push(refuses ? f : moveRepairs(f, fact));
468
+ }
469
+ // Placement, held to the predicate install plans from (the install spec,
470
+ // contract 6 as amended after the rc.3 tag): one well-formed block, seen by
471
+ // every harness the project uses. rc.1 and rc.2 left a block in an
472
+ // AGENTS.md-only project with no CLAUDE.md, so Claude Code saw nothing, and
473
+ // nothing said so. Used means detected by directory, or identified from the
474
+ // environment โ€” never the source harness a terminal run falls back to, so a
475
+ // Codex-only project hears nothing about CLAUDE.md (contract 16). An issue
476
+ // for the harness that identified itself, a warning for one only detected.
477
+ const blockFile = blocks === 1 && !refuses ? Object.keys(perFile)[0] : null;
478
+ if (blockFile) {
479
+ const identified = identifiedHarnessId(env);
480
+ const used = new Set([...detectHarnesses(proj).map((d) => d.id), ...(identified ? [identified] : [])]);
481
+ for (const id of used) {
482
+ const m = loadHarness(id);
483
+ if (!m || blockVisibleTo(m, blockFile, texts)) continue;
484
+ const ab = m.surfaces.agents_block;
485
+ const why = (ab.files || []).includes(blockFile)
486
+ ? `${ab.reads_natively} does not import it (\`@${blockFile}\`)`
487
+ : `it reads ${(ab.files || []).join(" and ")} only`;
488
+ const fact = `The projectstore:agents block is in ${blockFile}, which ${m.display_name} does not see: ${why}`;
489
+ // The resolved-root form the surface remedy uses: the running copy's own bin.
490
+ const remedy = `install the block for ${m.display_name}: node "${join(root, "bin", "projectstore.mjs")}" install --harness ${id} --surface agents_block --project "${proj}"`;
491
+ const f = finding("install", id === identified ? "issue" : "warn", "agents-block", `${fact} โ€” ${remedy}.`, blockFile);
492
+ // The layout's harness is the one the move's command installs for, so the
493
+ // move plans this import; another harness's is not the move's to repair.
494
+ out.push(id === sourceHarness()?.id ? moveRepairs(f, fact) : f);
495
+ }
445
496
  }
446
497
  return out;
447
498
  }
448
499
 
500
+ // While the layout move is pending, the findings the move itself repairs fold
501
+ // into it instead of being counted beside it (the layout spec, contract 7 as
502
+ // amended after the rc.3 tag; the upgrade story's O1): the stale block, which
503
+ // the move re-registers, and the block's invisibility to the layout's
504
+ // harness, whose import the move plans. Nothing else folds โ€” a block twice in
505
+ // one file, one that never closes or a wrapped marker makes the agents-block
506
+ // plan refuse, which blocks the move itself. The message points at the layout
507
+ // finding rather than computing its command again. The tag is internal: no
508
+ // finding leaves here carrying it.
509
+ export function foldIntoMove(findings) {
510
+ const layout = findings.find((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
511
+ const step = layout && (layout.check === "layout-two-configs"
512
+ ? "delete the binding the layout-two-configs finding names, then run the layout move, which repairs this"
513
+ : "the layout move repairs this: run what the layout-legacy finding names");
514
+ return findings.map(({ byMove, ...f }) => (byMove && layout ? { ...f, level: "info", message: `${byMove} โ€” ${step}.` } : f));
515
+ }
516
+
449
517
  // Both scopes are walked. The original reason ("project > user > plugin, so a
450
518
  // copy in either scope shadows the bundle") turned out to be wrong โ€” a copy
451
519
  // shadows NOTHING, because plugin agents register under a scoped id and copies
@@ -509,8 +577,10 @@ export async function checkHarnessSurfaces(_cfg, proj, { home = homedir(), root
509
577
  }
510
578
  } else if (s.surface === "agents_block") {
511
579
  // Version drift and duplicates are checkAgentsBlock's; what only the
512
- // state knows is content that differs at the same version, and a block
513
- // that never closes.
580
+ // state knows is content that differs at the same version. A block that
581
+ // never closes is named by both: checkAgentsBlock carries it to the
582
+ // startup line, and the state names it with the file's own reason, as a
583
+ // wrapped marker already was.
514
584
  if (s.state === "unparseable") {
515
585
  out.push(finding("install", "issue", "surface", `${where} โ€” ${s.reason}`, where));
516
586
  } else if (s.state === "ours-stale" && /content differs|migrates/.test(s.reason || "")) {
@@ -590,13 +660,38 @@ export function checkOverlays(cfg, proj, { root = pluginRoot(), home = homedir()
590
660
  // itself); a competing copy enabled beside ours โ†’ an issue (two enabled copies
591
661
  // of one plugin); a competitor alone โ†’ an info naming the npm path; foreign โ†’
592
662
  // never repairable; the host CLI missing โ†’ an info.
593
- export function checkPluginRegistration(proj, states = []) {
663
+ export function checkPluginRegistration(proj, states = [], { home = homedir() } = {}) {
594
664
  const out = [];
595
665
  for (const s of states.filter((x) => x.kind === "registration")) {
596
666
  // The shell form when the manifest names a shell (contract 12): the
597
667
  // command a user can paste, built in one place.
598
668
  const h = loadHarness(s.harness) || { id: s.harness };
599
669
  const refresh = packageCommand(h, "upgrade", { version: s.pkg || "latest", args: `--surface ${s.surface} --project "${proj}"` });
670
+ // A copy this registration silenced for the checkout and the checkout
671
+ // still holds off, one per key (the install spec, contract 13 as amended
672
+ // after the rc.3 tag): rc.1 and rc.2's startup offer left exactly this on
673
+ // git-marketplace projects, and nothing said so. Only for a registration
674
+ // this checkout holds; the record lives in our directory, so a directory
675
+ // removed by hand takes it along and leaves nothing to name.
676
+ if ((s.silenced || []).length && (s.state === "current" || s.state === "stale")) {
677
+ // The row this checkout loads: its own local-scope row first, then a
678
+ // user-scope one โ€” never another checkout's (layoutRemedy's rule).
679
+ const real = (x) => { try { return realpathSync.native(x); } catch { return resolve(x); } };
680
+ const here = real(proj);
681
+ const rows = installedPluginEntries(home, proj).filter((e) => e.present && (!e.projectPath || real(e.projectPath) === here));
682
+ for (const key of s.silenced) {
683
+ const row = rows.filter((e) => e.key === key).sort((a, b) => Number(Boolean(b.projectPath)) - Number(Boolean(a.projectPath)))[0];
684
+ if (!row) {
685
+ out.push(finding("install", "info", "plugin-registration", `${key} is held off in this checkout's local settings, where the npm registration turned it off โ€” and that copy is no longer installed, so the entry is stale.`, s.path));
686
+ continue;
687
+ }
688
+ // The release line, not the build: a 0.28 release candidate reads a
689
+ // moved project; 0.27.x reads it as unbound. No version reads as old.
690
+ const old = !row.version || cmpVersion(row.version, "0.28.0") < 0;
691
+ out.push(finding("install", "info", "plugin-registration",
692
+ `${key} (${row.version || "no version recorded"}) is off for this checkout: the npm registration turned it off when it registered, so the checkout no longer loads that copy and a /plugin update no longer reaches the project. ${old ? "Update that copy first โ€” 0.27.x reads a moved project as unbound. " : ""}To go back to it: from a terminal outside the session, ${packageCommand(h, "uninstall", { version: s.pkg || "latest", args: `--surface ${s.surface} --project "${proj}"` })}, restart, then /projectstore:doctor --fix. If moving to npm was meant, ignore this.`, s.path));
693
+ }
694
+ }
600
695
  const others = (s.others || []).map((o) => `${o.key} (${o.version || "?"})`).join(", ");
601
696
  if (s.state === "foreign") {
602
697
  out.push(finding("install", "issue", "plugin-registration-foreign", `${s.reason} โ€” install, uninstall and upgrade refuse it; nothing repairs it.`, s.path));
@@ -770,6 +865,21 @@ export function checkLayout(proj, harness = sourceHarness(), { level = "warn", r
770
865
  // the move never needs the registration, so a misread channel can cost a
771
866
  // launcher stamp but never a channel switch. A copy that predates the move
772
867
  // (no bin/projectstore.mjs โ€” 0.27.x) cannot run it: update that copy first.
868
+ // Nor can one whose CLI predates --no-register (takesNoRegister).
869
+ //
870
+ // Whether a registry copy can run the command named for it. `--no-register`
871
+ // arrived in 0.28.0-rc.3, and rc.1 and rc.2 parse strictly, so the command
872
+ // exits 2 on the flag and writes nothing (the layout spec, contract 12 as
873
+ // amended after the rc.3 tag). Read from the copy's own parse table, never
874
+ // from its version: a never-published 0.28.0 build ranks above rc.3 and lacks
875
+ // the flag, while a copy labelled rc.2 taken from main at 418448e has it. A
876
+ // file read, because this runs in the SessionStart subset: never an import()
877
+ // of the copy, never a spawn. Add files to the read, never drop cli.mjs:
878
+ // released copies keep their parse there.
879
+ export function takesNoRegister(copy) {
880
+ try { return readFileSync(join(copy, "scripts", "cli.mjs"), "utf8").includes('"no-register"'); } catch { return false; }
881
+ }
882
+
773
883
  export function layoutRemedy(proj, { root = pluginRoot(), home = homedir(), harness = sourceHarness(), env = process.env } = {}) {
774
884
  // A session under a relocated host home hands it on: a terminal without it
775
885
  // would classify the copy as a checkout, render no launcher and never clear
@@ -790,10 +900,22 @@ export function layoutRemedy(proj, { root = pluginRoot(), home = homedir(), harn
790
900
  .filter((e) => e.present && e.enabled && (!e.projectPath || mine(e)))
791
901
  .sort((a, b) => (Number(mine(b)) - Number(mine(a))) || (b.at - a.at))[0];
792
902
  if (copy) {
903
+ const v = copy.version ? ` (${copy.version})` : "";
793
904
  if (!existsSync(join(copy.path, "bin", "projectstore.mjs"))) {
794
- return { advice: `This project's projectstore plugin${copy.version ? ` (${copy.version})` : ""} predates the move and cannot run it: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), and the startup line names the command` };
905
+ return { advice: `This project's projectstore plugin${v} predates the move and cannot run it: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), and the startup line names the command` };
906
+ }
907
+ // Chosen first, gated after: the copy is what the project runs, so an
908
+ // incapable one gets advice even beside a capable user-scope copy.
909
+ const viaRegistration = installChannel(copy.path, { home, harness }) === "registration";
910
+ if (!takesNoRegister(copy.path)) {
911
+ return { advice: viaRegistration
912
+ // Its channel's ordinary refresh, at the running version: the plain
913
+ // upgrade re-registers from npm โ€” not a switch for an npm project โ€” and
914
+ // moves the project in the same run.
915
+ ? `This project's projectstore plugin${v} is the npm registration's copy and predates --no-register, so it cannot run the move as named: refresh that registration, which moves the project too, from a terminal outside the session: ${prefix}${packageCommand(harness, "upgrade", { version: pluginVersion(root) || "latest", args: `--project "${proj}"` })}`
916
+ : `This project's projectstore plugin${v} predates --no-register and cannot run the move as named: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), and the startup line names the command` };
795
917
  }
796
- return installChannel(copy.path, { home, harness }) === "registration" ? shell(copy.version) : own(copy.path);
918
+ return viaRegistration ? shell(copy.version) : own(copy.path);
797
919
  }
798
920
  return channel === "package" ? shell(pluginVersion(root)) : own(root);
799
921
  }
@@ -865,16 +987,6 @@ export function checkVaultGit(cfg) {
865
987
  "Vault is not a git repository โ€” the knowledge has no history/blame/review. Consider `git init` (doctor --fix offers it).")];
866
988
  }
867
989
 
868
- function versionNewer(a, b) {
869
- const pa = String(a).split(".").map(Number);
870
- const pb = String(b).split(".").map(Number);
871
- for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
872
- const x = pa[i] || 0, y = pb[i] || 0;
873
- if (x !== y) return x > y;
874
- }
875
- return false;
876
- }
877
-
878
990
  // Marketplace auto-update (maintainer request 2026-07-03): third-party
879
991
  // marketplaces do NOT auto-update by default, so a stale plugin looks like
880
992
  // "the feature is broken". Read the real registries and, when the flag is
@@ -948,7 +1060,8 @@ export function checkAutoUpdate(home = homedir()) {
948
1060
  const latest = (catalog.plugins || []).find((p) => p.name === name)?.version;
949
1061
  const running = (named && named.version)
950
1062
  || JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8")).version;
951
- if (latest && running && versionNewer(latest, running)) {
1063
+ // Precedence, not the triple: a session on 0.28.0-rc.3 hears that 0.28.0 is out.
1064
+ if (latest && running && cmpPrecedence(latest, running) > 0) {
952
1065
  out.push(finding("install", "warn", "auto-update",
953
1066
  `A newer ${name} is available: v${latest} (running v${running}) โ€” run /plugin marketplace update ${marketplace}, then /reload-plugins.`));
954
1067
  }
@@ -1852,7 +1965,7 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
1852
1965
  ...checkLayoutTemplates(cfg),
1853
1966
  ...checkHooksAlive(cfg),
1854
1967
  ...checkStatusline(cfg, proj),
1855
- ...checkAgentsBlock(proj),
1968
+ ...checkAgentsBlock(proj, { env: opts.env, root: opts.root }),
1856
1969
  ...checkOverrideCopies(proj),
1857
1970
  ...checkEnvModel(),
1858
1971
  ...checkEnvEffort(),
@@ -1867,9 +1980,9 @@ export async function runInstallChecks(cfg, proj, opts = {}) {
1867
1980
  let read = null;
1868
1981
  try { read = await readSurfaceStates(proj, opts); } catch {} // reported as a warn by checkHarnessSurfaces
1869
1982
  out.push(...await checkHarnessSurfaces(cfg, proj, { ...opts, read }));
1870
- if (read) out.push(...checkPluginRegistration(proj, read.result.states));
1983
+ if (read) out.push(...checkPluginRegistration(proj, read.result.states, { home: opts.home }));
1871
1984
  if (read) out.push(...checkVersionDrift(opts.home, read.result.states, proj));
1872
- return out;
1985
+ return foldIntoMove(out);
1873
1986
  }
1874
1987
 
1875
1988
  export function runVaultChecks(cfg) {
@@ -1932,24 +2045,26 @@ export function runStartupChecks(cfg, proj, budgetMs = 150) {
1932
2045
  ];
1933
2046
  const findings = [];
1934
2047
  for (const step of steps) {
1935
- if (Date.now() - started > budgetMs) return { skipped: true, count: 0, findings };
2048
+ if (Date.now() - started > budgetMs) return { skipped: true, count: 0, findings: foldIntoMove(findings) };
1936
2049
  try { findings.push(...step()); } catch {}
1937
2050
  }
2051
+ // The findings the move repairs are not counted beside it (foldIntoMove).
2052
+ const settled = foldIntoMove(findings);
1938
2053
  // While the move is pending, the re-stamp is not a step of its own: the move
1939
2054
  // re-stamps the launcher at its new path (the layout spec, contract 7 as
1940
2055
  // amended 2026-10-03), and an in-session `doctor --fix` would write that
1941
2056
  // launcher and then stop at the deferred move with exit 1. So the line names
1942
2057
  // one step, and says what it covers when the dropped offer would have fired.
1943
- const movePending = findings.some((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
1944
- const restamp = findings.some((f) => f.level === "info" && f.check === "upgrade");
1945
- const offers = findings
2058
+ const movePending = settled.some((f) => f.check === "layout-legacy" || f.check === "layout-two-configs");
2059
+ const restamp = settled.some((f) => f.level === "info" && f.check === "upgrade");
2060
+ const offers = settled
1946
2061
  .filter((f) => f.level === "info" && OFFER_CHECKS.has(f.check) && !(movePending && f.check === "upgrade"))
1947
2062
  .map((f) => (movePending && restamp && f.check === "layout-legacy" ? `${f.message} The same run re-stamps the status line launcher.` : f.message));
1948
2063
  return {
1949
2064
  skipped: false,
1950
- count: findings.filter((f) => f.level === "issue").length,
2065
+ count: settled.filter((f) => f.level === "issue").length,
1951
2066
  offers,
1952
- findings,
2067
+ findings: settled,
1953
2068
  };
1954
2069
  }
1955
2070
 
@@ -120,7 +120,49 @@ export function detectHarnessId(env = process.env, dir = MANIFEST_DIR) {
120
120
  function detect(env, dir) {
121
121
  const forced = env.PROJECTSTORE_HARNESS;
122
122
  if (forced && loadHarnesses(dir).has(forced)) return forced;
123
+ const best = ranked(env, dir);
124
+ // A strong signal is the harness identifying itself, and it decides.
125
+ if (best && best.strong) return best.id;
123
126
 
127
+ // Only weak signals. That is not enough to switch harness: the project
128
+ // itself carries better evidence โ€” whichever harness directory is present
129
+ // in it is the harness this project is used from. (Until 2026-09-06 the
130
+ // evidence was "which harness directory holds our config"; the binding is
131
+ // harness-neutral now and carries no such signal.)
132
+ const cwd = process.cwd();
133
+ for (const m of loadHarnesses(dir).values()) {
134
+ const d = m.runtime?.harness_dir;
135
+ if (d && existsSync(join(cwd, d))) return m.id;
136
+ }
137
+ if (best) return best.id;
138
+ const src = sourceHarness(dir);
139
+ return src ? src.id : null;
140
+ }
141
+
142
+ // The harness that identifies ITSELF from the environment, or null: a strong
143
+ // signal (its own plugin-root or project-dir variable, not one another
144
+ // harness shares), or its session marker โ€” runtime.session_env, which a Bash
145
+ // tool inside a session carries while the variables a hook receives are
146
+ // absent (measured 2026-09-05). Never the weak ranking, never the cwd's
147
+ // directory, never the source harness detectHarnessId falls back to. A
148
+ // finding that must not fire for a harness the project only might use asks
149
+ // this (the install spec, contract 6 as amended after the rc.3 tag).
150
+ export const IDENTIFIED_ENV = "PROJECTSTORE_IDENTIFIED";
151
+ export function identifiedHarnessId(env = process.env, dir = MANIFEST_DIR) {
152
+ const forced = env.PROJECTSTORE_HARNESS;
153
+ if (forced && loadHarnesses(dir).has(forced)) return forced;
154
+ // A parent of ours decided before it named the project in the harness's own
155
+ // vocabulary (childEnv), so its answer stands โ€” an empty one included.
156
+ if (env[IDENTIFIED_ENV] !== undefined) return loadHarnesses(dir).has(env[IDENTIFIED_ENV]) ? env[IDENTIFIED_ENV] : null;
157
+ const best = ranked(env, dir);
158
+ if (best && best.strong) return best.id;
159
+ for (const m of loadHarnesses(dir).values()) {
160
+ if ((m.runtime?.session_env || []).some((k) => env[k])) return m.id;
161
+ }
162
+ return null;
163
+ }
164
+
165
+ function ranked(env, dir) {
124
166
  // Explicit plugin-root/home variables beat merely-present ones: a shell that
125
167
  // exports CODEX_HOME globally should not make a Claude Code session read as
126
168
  // Codex when Claude Code also handed us CLAUDE_PLUGIN_ROOT.
@@ -168,22 +210,7 @@ function detect(env, dir) {
168
210
  const rank = (strongHits > 0 ? 1e6 : 0) + strongHits * 1e3 + weakHits;
169
211
  if (!best || rank > best.rank) best = { id: m.id, rank, strong: strongHits > 0 };
170
212
  }
171
- // A strong signal is the harness identifying itself, and it decides.
172
- if (best && best.strong) return best.id;
173
-
174
- // Only weak signals. That is not enough to switch harness: the project
175
- // itself carries better evidence โ€” whichever harness directory is present
176
- // in it is the harness this project is used from. (Until 2026-09-06 the
177
- // evidence was "which harness directory holds our config"; the binding is
178
- // harness-neutral now and carries no such signal.)
179
- const cwd = process.cwd();
180
- for (const m of loadHarnesses(dir).values()) {
181
- const d = m.runtime?.harness_dir;
182
- if (d && existsSync(join(cwd, d))) return m.id;
183
- }
184
- if (best) return best.id;
185
- const src = sourceHarness(dir);
186
- return src ? src.id : null;
213
+ return best;
187
214
  }
188
215
 
189
216
  // Test seam: the detected id is memoised for the process; a test that changes
@@ -474,6 +501,11 @@ export function configPath(projectDir, env = process.env) {
474
501
  export function childEnv(base = process.env, { projectRoot: root, pluginRoot: plugin } = {}) {
475
502
  const out = { ...base };
476
503
  const r = activeHarness(base)?.runtime || {};
504
+ // Who called is decided here, before the project is named below: a
505
+ // project-dir variable we write would otherwise read, in the child, as the
506
+ // harness identifying itself โ€” a terminal `doctor` took Claude Code for the
507
+ // session's own harness (the review of the post-rc.3 fixes, 2026-10-04).
508
+ if (base[IDENTIFIED_ENV] === undefined) out[IDENTIFIED_ENV] = identifiedHarnessId(base) || "";
477
509
  if (r.project_dir_env && root) out[r.project_dir_env] = root;
478
510
  // A caller that runs its own copy of the core (the npm bin) names it, so a
479
511
  // child never resolves templates or its version from a sibling install.
@@ -67,7 +67,7 @@ import { loadHarness, loadHarnesses, harnessIds, sourceHarness, detectHarnesses,
67
67
  import { FOREIGN_TEXT, GRAMMAR_VERSION } from "./provenance.mjs";
68
68
  import { analyseBlock, analyseJsonEntry, analyseStampedFile, analyseRegistration, analysePortableRegistration, analyseLayout, isOurFile, readText } from "./surfaces.mjs";
69
69
  import { payloadFiles, renderPortableCatalog } from "./portable-registration.mjs";
70
- import { pluginRoot, writeFileAtomic, writeExclusiveMetadata, ensureStateDir, ensureRuntimeDir, removeAgentsBlock, replaceAgentsBlock, readConfigAt, isPluginCacheRoot, isEphemeralRoot, statusLineIsOurWiring, claudeHome, packageDigest, writeOwnTree, removeOwnTree, cmpVersion, whichOnPath as whichOnPathFromLib, moveStateDir, mergeEntryLog, movePath, removeInside, statusLineScriptPath, layoutPaths, stagePortableMarketplace, finishPortableMarketplace, rollbackPortableMarketplace, removeTreeUnder } from "./lib.mjs";
70
+ import { pluginRoot, writeFileAtomic, writeExclusiveMetadata, ensureStateDir, ensureRuntimeDir, removeAgentsBlock, replaceAgentsBlock, readConfigAt, isPluginCacheRoot, isEphemeralRoot, statusLineIsOurWiring, claudeHome, packageDigest, writeOwnTree, removeOwnTree, cmpPrecedence, importsLine, whichOnPath as whichOnPathFromLib, moveStateDir, mergeEntryLog, movePath, removeInside, statusLineScriptPath, layoutPaths, stagePortableMarketplace, finishPortableMarketplace, rollbackPortableMarketplace, removeTreeUnder } from "./lib.mjs";
71
71
 
72
72
  import { GENERATOR } from "./surfaces.mjs";
73
73
  export { GENERATOR };
@@ -112,7 +112,7 @@ function planAgentsBlock(ctx, key, s) {
112
112
  const items = [];
113
113
  if (a.refusal) return [{ surface: key, kind: "shared", path: a.files[0].path, entry: "projectstore:agents", state: "refused", action: "refuse", reason: a.refusal }];
114
114
 
115
- const hasImport = (text) => String(text ?? "").split("\n").some((l) => l.trim() === importLine);
115
+ const hasImport = (text) => importsLine(text, importLine);
116
116
  // A CLAUDE.md that is nothing but the import registration added is ours to
117
117
  // delete when the block goes (ADR-002 decision 4); anything else stays.
118
118
  const onlyImport = (text) => String(text ?? "").split("\n").every((l) => !l.trim() || l.trim() === importLine);
@@ -256,7 +256,7 @@ function planAgentsBlock(ctx, key, s) {
256
256
  const rewrite = items.find((i) => i.action === "remove" && i.path === e.path);
257
257
  // An absent native file is empty text, not a reason to skip: it is created.
258
258
  const text = rewrite ? rewrite.after : (e.present ? e.text : "");
259
- if (typeof text !== "string" || text.split("\n").some((l) => l.trim() === line)) continue;
259
+ if (typeof text !== "string" || importsLine(text, line)) continue;
260
260
  const after = line + "\n" + (text.startsWith("\n") || !text.trim() ? "" : "\n") + text;
261
261
  if (rewrite) { rewrite.after = after; rewrite.deleteIfEmpty = false; rewrite.reason += `; ${line} import added`; }
262
262
  else items.push({ surface: `${key}_import`, kind: "shared", path: e.path, entry: line, state: "ours-absent", action: e.present ? "add" : "create",
@@ -271,14 +271,17 @@ function planAgentsBlock(ctx, key, s) {
271
271
  // One host command as a plan step: the verbatim argv (the manifest's
272
272
  // subcommand with its placeholders filled), why it runs, and the host-owned
273
273
  // files it is known to touch (measured 2026-09-05 โ€” the manifest's cli.verified).
274
+ // A portable registration keeps its marketplace and enablement stanzas in one
275
+ // global config (registry.global_config; Codex's config.toml, measured
276
+ // 2026-09-07), so the preview names that file wherever a step rewrites it.
274
277
  function hostStep(a, s, name, fill, why) {
275
278
  const template = s.cli.commands[name];
276
279
  if (!Array.isArray(template) || !template.length) throw new Error(`${s.format}: host operation ${name} is not declared`);
277
280
  const argv = template.map((t) => t.replace(/\{(\w+)\}/g, (_, k) => fill[k] ?? `{${k}}`));
278
281
  const p = a.paths;
279
282
  const touches = {
280
- validate: [], marketplace_add: [p.marketplaces, p.projectSettings], marketplace_update: [p.marketplaces], marketplace_remove: [p.marketplaces, p.projectSettings],
281
- install: [p.installed, p.projectSettings, p.cacheDir], update: [p.installed, p.cacheDir], uninstall: [p.installed, p.projectSettings], disable: [p.projectSettings], enable: [p.projectSettings],
283
+ validate: [], marketplace_add: [p.marketplaces, p.globalConfig, p.projectSettings], marketplace_update: [p.marketplaces], marketplace_remove: [p.marketplaces, p.globalConfig, p.projectSettings],
284
+ install: [p.installed, p.globalConfig, p.projectSettings, p.cacheDir], update: [p.installed, p.cacheDir], uninstall: [p.installed, p.globalConfig, p.projectSettings], disable: [p.projectSettings], enable: [p.projectSettings],
282
285
  }[name] || [];
283
286
  return { kind: "host", name, bin: s.cli.bin, argv, why, touches: touches.filter(Boolean) };
284
287
  }
@@ -455,7 +458,7 @@ function planRegistration(ctx, key, s) {
455
458
  // pack โ†’ install โ†’ fix โ†’ pack loop never bumps it. The host will not
456
459
  // re-copy at an equal version (measured), so that refresh is uninstall + install.
457
460
  const sameVersionDiffers = a.contentDiffers === true;
458
- const rewrite = !a.dir.present || (cmpVersion(a.dir.pkg, a.pkg) < 0) || a.dir.digestOk === false || sameVersionDiffers;
461
+ const rewrite = !a.dir.present || (cmpPrecedence(a.dir.pkg, a.pkg) < 0) || a.dir.digestOk === false || sameVersionDiffers;
459
462
  const disabled = [...new Set([...a.dir.disabled, ...a.others.map((o) => o.key)])];
460
463
  const digest = rewrite ? packageDigest(root) : (a.dir.prov?.digest || null);
461
464
  const files = digest ? digest.count : 0;
@@ -596,7 +599,7 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
596
599
  // once whatever --surface names โ€” planned first, so every surface below is
597
600
  // planned against the new paths; its cleanup is planned last (below).
598
601
  const layoutHarness = loadHarness(ids.find((id) => { const p = layoutPaths(projectDir, { harnessDir: loadHarness(id).runtime?.harness_dir || null }); return existsSync(p.legacy.binding) || existsSync(p.legacy.runtime); }) || ids[0]);
599
- const layoutCtx = { projectDir, mode, env, home, root, harness: layoutHarness, incomplete: false };
602
+ const layoutCtx = { projectDir, mode, env, home, root, harness: layoutHarness, incomplete: false, surfaces };
600
603
  const layout = planLayout(layoutCtx);
601
604
  for (const item of layout.first) out.items.push({ harness: layoutHarness.id, ...item });
602
605
  if (layoutCtx.incomplete) out.incomplete = true;
@@ -691,7 +694,12 @@ function planLayout(ctx) {
691
694
  const base = { surface: "layout", kind: "layout", path: P.legacy.binding, entry: null, state: a.state, reason: null };
692
695
  if (mode === "uninstall") {
693
696
  // Disowning: the legacy runtime directory goes with the new one when it is
694
- // ours (its header); the legacy binding is bind's, never uninstall's.
697
+ // ours (its header); the legacy binding is bind's, never uninstall's. A
698
+ // narrowed uninstall leaves it alone: the way back from an npm switch is
699
+ // `uninstall --surface plugin`, and it deleted a not-yet-moved project's
700
+ // sessions, log and the launcher its status line still ran (the critic's
701
+ // fourth pass, 2026-10-04). `surfaces` is null when nothing was named.
702
+ if (ctx.surfaces) return none;
695
703
  if (!a.legacy.runtime || !a.legacy.runtimeOurs) return none;
696
704
  return { first: [], last: [{ ...base, surface: "layout_cleanup", path: P.legacy.runtime, state: "legacy", action: "remove", reason: "the legacy runtime directory is ours (its .gitignore header) and goes with the state", steps: [
697
705
  { kind: "remove-legacy-runtime", path: P.legacy.runtime, why: "the pre-0.28 state directory, removed whole" },
@@ -714,7 +722,7 @@ function planLayout(ctx) {
714
722
  const last = [];
715
723
  if (a.legacy.launcher || a.legacy.runtime) {
716
724
  const cleanup = [];
717
- if (a.legacy.launcher) cleanup.push({ kind: "remove-legacy-launcher", path: P.legacy.launcher, why: "removed once the new launcher is written and the settings entry names it โ€” or at once when the status-line slot is not ours (foreign, or empty because the status line is off); kept if anything still points at it" });
725
+ if (a.legacy.launcher) cleanup.push({ kind: "remove-legacy-launcher", path: P.legacy.launcher, why: "removed once the new launcher is written and the settings entry names it โ€” or at once when the status-line slot is not ours (foreign, or empty because the status line is off); kept while a settings file the host reads (this project's local or committed settings, or the user's) still runs it as the status line โ€” a script that calls it indirectly is not seen" });
718
726
  if (a.legacy.runtime) cleanup.push({ kind: "rmdir-legacy", path: P.legacy.runtime, why: "the emptied pre-0.28 runtime directory" });
719
727
  last.push({ ...base, surface: "layout_cleanup", path: P.legacy.runtime, state: "legacy", action: "cleanup", reason: null, steps: cleanup });
720
728
  }
@@ -767,7 +775,7 @@ function applyLayout(p, i, { failed, home = homedir() }) {
767
775
  if (text === null) out.steps.push({ kind: st.kind, ok: true, removed: false });
768
776
  else if (!moved && slotOurs) out.steps.push({ kind: st.kind, ok: true, removed: false, reason: "no launcher at the new path yet (this root does not produce one) โ€” left in place" });
769
777
  else if (!isOurFile(text)) out.steps.push({ kind: st.kind, ok: true, removed: false, reason: "not ours โ€” left in place" });
770
- else if (named) out.steps.push({ kind: st.kind, ok: true, removed: false, reason: "the settings entry still names it โ€” left in place" });
778
+ else if (named) out.steps.push({ kind: st.kind, ok: true, removed: false, reason: "a settings file the host reads still runs it as the status line โ€” left in place" });
771
779
  else out.steps.push({ kind: st.kind, ok: true, removed: removeInside(st.path, within) });
772
780
  }
773
781
  else if (st.kind === "rmdir-legacy") {
@@ -318,6 +318,9 @@ export function claudeHome(home = homedir()) {
318
318
  return harnessAgentHome(process.env, home);
319
319
  }
320
320
 
321
+ // The release triple only: 0.28.0-rc.2 and 0.28.0 compare equal. Right for
322
+ // "which release line" (the layout window's sunset); wrong for "which build is
323
+ // newer" โ€” that is cmpPrecedence.
321
324
  export function cmpVersion(a, b) {
322
325
  const A = String(a || "0").split(".").map((n) => parseInt(n, 10) || 0);
323
326
  const B = String(b || "0").split(".").map((n) => parseInt(n, 10) || 0);
@@ -325,6 +328,33 @@ export function cmpVersion(a, b) {
325
328
  return 0;
326
329
  }
327
330
 
331
+ // Semver 2.0 precedence: 0.28.0-rc.2 < 0.28.0-rc.3 < 0.28.0. A registration
332
+ // made at a release candidate read as current against the release while the
333
+ // comparison stopped at the triple, so `upgrade` never refreshed it (the
334
+ // critic's probe, 2026-10-03). Build metadata (`+โ€ฆ`) never ranks.
335
+ export function cmpPrecedence(a, b) {
336
+ const parse = (v) => {
337
+ const s = String(v || "0").split("+")[0];
338
+ const dash = s.indexOf("-");
339
+ return { core: (dash < 0 ? s : s.slice(0, dash)).split(".").map((n) => parseInt(n, 10) || 0), pre: dash < 0 ? [] : s.slice(dash + 1).split(".") };
340
+ };
341
+ const A = parse(a), B = parse(b);
342
+ for (let i = 0; i < 3; i++) if ((A.core[i] || 0) !== (B.core[i] || 0)) return (A.core[i] || 0) - (B.core[i] || 0);
343
+ // A release outranks every candidate for it.
344
+ if (!A.pre.length || !B.pre.length) return B.pre.length - A.pre.length;
345
+ for (let i = 0; i < Math.max(A.pre.length, B.pre.length); i++) {
346
+ if (i >= A.pre.length) return -1;
347
+ if (i >= B.pre.length) return 1;
348
+ const x = A.pre[i], y = B.pre[i];
349
+ const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
350
+ if (nx && ny) { if (Number(x) !== Number(y)) return Number(x) - Number(y); continue; }
351
+ // A numeric identifier ranks below an alphanumeric one.
352
+ if (nx !== ny) return nx ? -1 : 1;
353
+ if (x !== y) return x < y ? -1 : 1;
354
+ }
355
+ return 0;
356
+ }
357
+
328
358
  // Where the CURRENTLY installed projectstore lives, per Claude Code's own
329
359
  // plugin registry. The cache path carries the version
330
360
  // (โ€ฆ/plugins/cache/<marketplace>/projectstore/<version>), so anything that
@@ -863,6 +893,30 @@ export function findAgentsBlock(text) {
863
893
  return { present: true, v: Number(m[1]), start: m.index, end, unclosed: false, count, block: text.slice(m.index, end) };
864
894
  }
865
895
 
896
+ // Whether `text` holds `line` as a line of its own, trimmed and exact โ€” the
897
+ // installer's match for the `@<file>` import, so `@./AGENTS.md` reads as
898
+ // absent to install and doctor alike.
899
+ export function importsLine(text, line) {
900
+ return String(text ?? "").split("\n").some((l) => l.trim() === line);
901
+ }
902
+
903
+ // Whether a harness sees the one agents block where it stands โ€” the predicate
904
+ // doctor reports by (the install spec, contract 6 as amended after the rc.3
905
+ // tag). Install's placement rules agree with it: it moves a block whose file
906
+ // is not among the harness's files and bridges one that is, through the same
907
+ // import match (importsLine); a test holds the two to one answer. Visible when
908
+ // the block's file is among the harness's agents_block.files and is either the
909
+ // file it reads by itself or imported from that file. A harness with no agents
910
+ // block has nothing to see it with. `texts` maps a file name to its text; an
911
+ // absent file is undefined.
912
+ export function blockVisibleTo(manifest, file, texts = {}) {
913
+ const ab = manifest?.surfaces?.agents_block;
914
+ if (!ab || ab.supported === false) return true;
915
+ if (!(ab.files || []).includes(file)) return false;
916
+ const native = ab.reads_natively;
917
+ return !native || native === file || importsLine(texts[native], `@${file}`);
918
+ }
919
+
866
920
  // Replace the block in place, else append it after the user's own content.
867
921
  export function replaceAgentsBlock(text, block) {
868
922
  const base = String(text ?? "");
@@ -47,7 +47,7 @@ import {
47
47
  pluginEnabled,
48
48
  treeDigest,
49
49
  packageDigest,
50
- cmpVersion,
50
+ cmpPrecedence,
51
51
  layoutPaths,
52
52
  RUNTIME_GITIGNORE_HEADER,
53
53
  LAUNCHER_HEADER,
@@ -284,7 +284,7 @@ export function analyseRegistration(projectDir, s, { root = pluginRoot(), home =
284
284
  if (e.projectPath && !samePath(e.projectPath, projectDir)) continue;
285
285
  seen.add(e.key); a.others.push({ key: e.key, path: e.path, version: e.version });
286
286
  }
287
- a.newer = Boolean(a.dir.pkg) && cmpVersion(a.dir.pkg, pkg) > 0;
287
+ a.newer = Boolean(a.dir.pkg) && cmpPrecedence(a.dir.pkg, pkg) > 0;
288
288
  // Same version, different payload: the directory's recorded digest against
289
289
  // this package's โ€” contract 4's rung 3 (source changed) for a directory.
290
290
  a.contentDiffers = null;
@@ -307,7 +307,7 @@ export function analyseRegistration(projectDir, s, { root = pluginRoot(), home =
307
307
  // The directory is shared by every checkout on the machine; a project that never registered is absent, whatever the directory holds.
308
308
  if (nothingOfOursHere) return { ...a, state: "absent", reason: a.dir.present ? `the marketplace directory is present${a.writtenBy ? ` (written from ${a.writtenBy})` : ""}; this checkout is not registered` : null };
309
309
  if (!a.dir.present) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + " (the directory is gone)" };
310
- if (cmpVersion(a.dir.pkg, pkg) < 0) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + ` (directory at ${a.dir.pkg}, package at ${pkg})` };
310
+ if (cmpPrecedence(a.dir.pkg, pkg) < 0) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + ` (directory at ${a.dir.pkg}, package at ${pkg})` };
311
311
  if (a.dir.digestOk === false) return { ...a, state: "stale", reason: "edited by hand or half-written (the payload does not match the digest its manifest carries)" };
312
312
  if (a.contentDiffers === true) return { ...a, state: "stale", reason: STALE_TEXT[STALE.PLUGIN] + ` (same version ${pkg}, different content โ€” the directory's digest is not this package's)` };
313
313
  if (!a.known || !a.registeredHere) return { ...a, state: "stale", reason: STALE_TEXT[STALE.CONFIG] + (a.known ? " (this checkout does not declare the marketplace)" : " (the host does not know the marketplace)") };
@@ -397,6 +397,9 @@ export function surfaceStates(projectDir, { home = homedir(), root = pluginRoot(
397
397
  writtenBy: a.writtenBy || null,
398
398
  newer: Boolean(a.newer),
399
399
  bin: a.bin,
400
+ // What this registration silenced for the checkout and the checkout
401
+ // still holds off: exactly the set uninstall re-enables (contract 13).
402
+ silenced: (a.dir?.disabled || []).filter((k) => (a.disabledHere || []).includes(k)),
400
403
  });
401
404
  states.push(row);
402
405
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore-claude",
3
- "version": "0.28.0-rc.3",
3
+ "version": "0.28.1",
4
4
  "description": "Installs projectstore for Claude Code from npm: the core pinned and bundled, the harness fixed โ€” npx projectstore-claude install --project \"$PWD\".",
5
5
  "keywords": [
6
6
  "projectstore",
@@ -37,7 +37,7 @@
37
37
  "README.md"
38
38
  ],
39
39
  "dependencies": {
40
- "projectstore": "=0.28.0-rc.3"
40
+ "projectstore": "=0.28.1"
41
41
  },
42
42
  "bundleDependencies": [
43
43
  "projectstore"