projectstore-claude 0.28.0-rc.3 โ 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/README.md +16 -2
- package/node_modules/projectstore/commands/agents.md +7 -1
- package/node_modules/projectstore/commands/doctor.md +10 -3
- package/node_modules/projectstore/docs/harnesses.md +20 -10
- package/node_modules/projectstore/harnesses/claude-code.json +1 -1
- package/node_modules/projectstore/harnesses/codex.json +2 -2
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/doctor.mjs +146 -31
- package/node_modules/projectstore/scripts/harness.mjs +48 -16
- package/node_modules/projectstore/scripts/install-harness.mjs +13 -8
- package/node_modules/projectstore/scripts/lib.mjs +54 -0
- package/node_modules/projectstore/scripts/surfaces.mjs +6 -3
- package/package.json +2 -2
|
@@ -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
|
|
15
|
+
"version": "0.28.0",
|
|
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
|
|
4
|
+
"version": "0.28.0",
|
|
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",
|
|
@@ -168,9 +168,14 @@ bound before 0.28. What an existing project sees afterwards, and why:
|
|
|
168
168
|
`npx projectstore-claude upgrade` on a git-marketplace install does more: it
|
|
169
169
|
also registers the plugin from npm for this checkout and turns the
|
|
170
170
|
git-marketplace copy off here, so `/plugin update` stops reaching the
|
|
171
|
-
checkout.
|
|
171
|
+
checkout. The 0.28.0-rc.1 and rc.2 startup lines named that shell form
|
|
172
|
+
(`npx projectstore-claude@<version> upgrade`, without `--no-register`), so on
|
|
173
|
+
those, update first and run what the new startup line names. If it already
|
|
174
|
+
happened,
|
|
172
175
|
`npx projectstore-claude@<version> uninstall --surface plugin --project "$PWD"`
|
|
173
|
-
turns the git-marketplace copy back on.
|
|
176
|
+
(0.28.0 or later) turns the git-marketplace copy back on. That copy must be 0.28 or later โ an
|
|
177
|
+
older one reads a moved project as unbound โ so update it first if it is not.
|
|
178
|
+
Restart, then run
|
|
174
179
|
`/projectstore:doctor --fix` in the new session, which re-stamps the status
|
|
175
180
|
line against that copy.
|
|
176
181
|
- **The status line keeps rendering.** A launcher written by an earlier version
|
|
@@ -197,6 +202,15 @@ bound before 0.28. What an existing project sees afterwards, and why:
|
|
|
197
202
|
(`projectstore-decision-detector`, `projectstore-peer-reviewer`,
|
|
198
203
|
`projectstore-story-completion`, `projectstore-vault-communication`). Nothing
|
|
199
204
|
in a project names them; only a skill listing shows the new names.
|
|
205
|
+
- **Rolling back** to 0.27.x before the move: everything keeps working, since
|
|
206
|
+
0.27.x still reads the old layout. It rewrites the status-line launcher in its
|
|
207
|
+
own form, as it always did; coming forward again, the startup line names the
|
|
208
|
+
move once more, and the move re-stamps the launcher. If a session already
|
|
209
|
+
re-stamped the status line or re-registered the agents block before the move
|
|
210
|
+
(rc.2's `/projectstore:doctor --fix` did both), 0.27.x reports a foreign
|
|
211
|
+
status line and a v4 block and leaves both alone โ unless you run its
|
|
212
|
+
`/projectstore:agents register`, which rewrites the block; the status line
|
|
213
|
+
still renders, and the move settles both.
|
|
200
214
|
- **Rolling back** to 0.27.x after the move: 0.27.x looks for its binding under
|
|
201
215
|
`.claude/`, finds none and offers `bind`. Do not accept. A re-bind writes
|
|
202
216
|
`.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.
|
|
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.
|
|
78
|
-
|
|
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.
|
|
@@ -45,20 +45,30 @@ model names and those are harness-specific โ is
|
|
|
45
45
|
|
|
46
46
|
Experimental, measured on `codex-cli 0.153.4`. The initial spike captured 759
|
|
47
47
|
hook firings. The 2026-09-30 gate then built the npm shell from a packed core,
|
|
48
|
-
passed
|
|
48
|
+
passed Codex's plugin validator, installed and upgraded it through an isolated
|
|
49
49
|
`CODEX_HOME`, verified the materialised cache by version and digest, and loaded
|
|
50
|
-
`$projectstore-status` in a fresh Codex session.
|
|
50
|
+
`$projectstore-status` in a fresh Codex session. The validator (the
|
|
51
|
+
plugin-creator skill's `validate_plugin.py`) reads only
|
|
52
|
+
`.codex-plugin/plugin.json`, so the canonical root `plugin.json` has not been
|
|
53
|
+
validated.
|
|
51
54
|
|
|
52
55
|
That run exercised one skill, not every installed surface, so `verified` stays
|
|
53
56
|
`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
|
|
55
|
-
hand through `zsh -lc
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
the
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
57
|
+
install's cached hooks could not load the vault in any session. They were run
|
|
58
|
+
by hand through `zsh -lc`; the failure did not depend on the shell form. In the
|
|
59
|
+
first session the failure sat in the model's context under the welcome, which
|
|
60
|
+
was all the user saw. The core had taken the shell's root for its own. That is
|
|
61
|
+
fixed, and the suite now runs every rendered hook from the built shell. A live
|
|
62
|
+
Codex session firing them from an installed release is still owed.
|
|
63
|
+
|
|
64
|
+
How 0.153.4 starts a hook was read from its source, not measured: under the
|
|
65
|
+
session's shell as `<shell> -c`, with the environment the Codex process had when
|
|
66
|
+
it built the session's hooks. `$SHELL -lc` (or `/bin/sh -lc`) is the fallback
|
|
67
|
+
when the hooks are built without exactly one ready local environment. So a hook
|
|
68
|
+
finds `node` on the Codex process's own `PATH`, plus whatever `.zshenv` adds:
|
|
69
|
+
zsh reads `.zshenv` for a `-c` command, not `.zprofile` or `.zshrc`, so a Codex
|
|
70
|
+
not started from a terminal can miss a `node` that only `.zprofile` puts on
|
|
71
|
+
`PATH`.
|
|
62
72
|
|
|
63
73
|
The shell is not published. Build it and install the tarball through npx from
|
|
64
74
|
this checkout:
|
|
@@ -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.
|
|
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 has not been validated.",
|
|
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` (
|
|
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.",
|
|
11
11
|
"runtime": {
|
|
12
12
|
"detect_env": [
|
|
13
13
|
"PLUGIN_ROOT",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore",
|
|
3
|
-
"version": "0.28.0
|
|
3
|
+
"version": "0.28.0",
|
|
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
|
-
|
|
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
|
-
|
|
444
|
-
|
|
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
|
|
513
|
-
//
|
|
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${
|
|
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
|
|
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
|
-
|
|
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 =
|
|
1944
|
-
const restamp =
|
|
1945
|
-
const offers =
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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) =>
|
|
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
|
|
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",
|
|
@@ -455,7 +455,7 @@ function planRegistration(ctx, key, s) {
|
|
|
455
455
|
// pack โ install โ fix โ pack loop never bumps it. The host will not
|
|
456
456
|
// re-copy at an equal version (measured), so that refresh is uninstall + install.
|
|
457
457
|
const sameVersionDiffers = a.contentDiffers === true;
|
|
458
|
-
const rewrite = !a.dir.present || (
|
|
458
|
+
const rewrite = !a.dir.present || (cmpPrecedence(a.dir.pkg, a.pkg) < 0) || a.dir.digestOk === false || sameVersionDiffers;
|
|
459
459
|
const disabled = [...new Set([...a.dir.disabled, ...a.others.map((o) => o.key)])];
|
|
460
460
|
const digest = rewrite ? packageDigest(root) : (a.dir.prov?.digest || null);
|
|
461
461
|
const files = digest ? digest.count : 0;
|
|
@@ -596,7 +596,7 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
|
|
|
596
596
|
// once whatever --surface names โ planned first, so every surface below is
|
|
597
597
|
// planned against the new paths; its cleanup is planned last (below).
|
|
598
598
|
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 };
|
|
599
|
+
const layoutCtx = { projectDir, mode, env, home, root, harness: layoutHarness, incomplete: false, surfaces };
|
|
600
600
|
const layout = planLayout(layoutCtx);
|
|
601
601
|
for (const item of layout.first) out.items.push({ harness: layoutHarness.id, ...item });
|
|
602
602
|
if (layoutCtx.incomplete) out.incomplete = true;
|
|
@@ -691,7 +691,12 @@ function planLayout(ctx) {
|
|
|
691
691
|
const base = { surface: "layout", kind: "layout", path: P.legacy.binding, entry: null, state: a.state, reason: null };
|
|
692
692
|
if (mode === "uninstall") {
|
|
693
693
|
// 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.
|
|
694
|
+
// ours (its header); the legacy binding is bind's, never uninstall's. A
|
|
695
|
+
// narrowed uninstall leaves it alone: the way back from an npm switch is
|
|
696
|
+
// `uninstall --surface plugin`, and it deleted a not-yet-moved project's
|
|
697
|
+
// sessions, log and the launcher its status line still ran (the critic's
|
|
698
|
+
// fourth pass, 2026-10-04). `surfaces` is null when nothing was named.
|
|
699
|
+
if (ctx.surfaces) return none;
|
|
695
700
|
if (!a.legacy.runtime || !a.legacy.runtimeOurs) return none;
|
|
696
701
|
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
702
|
{ kind: "remove-legacy-runtime", path: P.legacy.runtime, why: "the pre-0.28 state directory, removed whole" },
|
|
@@ -714,7 +719,7 @@ function planLayout(ctx) {
|
|
|
714
719
|
const last = [];
|
|
715
720
|
if (a.legacy.launcher || a.legacy.runtime) {
|
|
716
721
|
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
|
|
722
|
+
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
723
|
if (a.legacy.runtime) cleanup.push({ kind: "rmdir-legacy", path: P.legacy.runtime, why: "the emptied pre-0.28 runtime directory" });
|
|
719
724
|
last.push({ ...base, surface: "layout_cleanup", path: P.legacy.runtime, state: "legacy", action: "cleanup", reason: null, steps: cleanup });
|
|
720
725
|
}
|
|
@@ -767,7 +772,7 @@ function applyLayout(p, i, { failed, home = homedir() }) {
|
|
|
767
772
|
if (text === null) out.steps.push({ kind: st.kind, ok: true, removed: false });
|
|
768
773
|
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
774
|
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: "
|
|
775
|
+
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
776
|
else out.steps.push({ kind: st.kind, ok: true, removed: removeInside(st.path, within) });
|
|
772
777
|
}
|
|
773
778
|
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
|
-
|
|
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) &&
|
|
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 (
|
|
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
|
|
3
|
+
"version": "0.28.0",
|
|
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
|
|
40
|
+
"projectstore": "=0.28.0"
|
|
41
41
|
},
|
|
42
42
|
"bundleDependencies": [
|
|
43
43
|
"projectstore"
|