projectstore-claude 0.29.1 → 0.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,6 +11,7 @@ It registers the plugin for that checkout at the host's local scope. It prints i
11
11
  - See before you write: `npx projectstore-claude plan --project "$PWD"` prints the same plan and writes nothing; `--verbose` adds every row's reasoning; `npx projectstore-claude <verb> --help` lists a verb's options with examples.
12
12
 
13
13
  - Upgrade, or pin: `npx projectstore-claude@<version> upgrade --project "$PWD"` — the version you name is the version you run.
14
+ - Behind a registry mirror: if `npx` answers `could not determine executable to run`, the mirror's index knows only the reserved `0.0.1` placeholder — name the version, and if needed the public registry: `npx --registry https://registry.npmjs.org projectstore-claude@<version> install --project "$PWD"`.
14
15
  - Uninstall: `npx projectstore-claude uninstall --project "$PWD"` — forgets the registration for that checkout and nothing else; your vault is plain markdown and stays yours.
15
16
  - `doctor`, `status`, `search` and the other read verbs pass through unchanged: `npx projectstore-claude doctor --json`.
16
17
 
@@ -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.29.1",
15
+ "version": "0.29.2",
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.29.1",
4
+ "version": "0.29.2",
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",
@@ -101,12 +101,20 @@ The same tree is published to npm as [`projectstore`](https://www.npmjs.com/pack
101
101
  plugin manifest, namespaced workflow and role skills, lifecycle hooks, and
102
102
  the exact bundled core. It stages a stable marketplace under `CODEX_HOME`,
103
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,
104
+ installation back and verifies its version and payload digest. Before it
105
+ changes anything it asks Codex for that read-back (`codex plugin list --json`),
106
+ so a Codex too old to answer stops the registration before it changes anything
107
+ and tells you to upgrade Codex. Restart Codex,
105
108
  approve the hooks, and run `$projectstore-bind <vault-path>`. For later
106
109
  releases, `npx projectstore-codex@<version> upgrade --project "$PWD"`. Because
107
110
  Codex's plugin registry is user-global, ordinary uninstall removes only the
108
111
  project's agents block; `uninstall --global` is the explicit machine-wide
109
- removal. Codex is **experimental** until a live session has exercised every
112
+ removal. If `npx` answers `could not determine executable to run`, your
113
+ registry (often a company mirror) serves a stale index that knows only the
114
+ reserved `0.0.1` placeholder: name the version, and if that is not enough, the
115
+ public registry: `npx --registry https://registry.npmjs.org
116
+ projectstore-codex@<version> install --project "$PWD"` (the same holds for
117
+ `projectstore-claude`). Codex is **experimental** until a live session has exercised every
110
118
  surface it installs; [`docs/harnesses.md`](./docs/harnesses.md) says what has
111
119
  been measured and what has not. From a checkout, the same npx path runs against
112
120
  a built tarball:
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore",
3
- "version": "0.29.1",
3
+ "version": "0.29.2",
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",
@@ -43,7 +43,7 @@ import { fileURLToPath } from "node:url";
43
43
  import { parseArgs } from "node:util";
44
44
  import * as term from "./term.mjs";
45
45
  import { projectRootDeclared, childEnv, harnessIds, harnessForOverlay, pinPluginRoot } from "./harness.mjs";
46
- import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster, commandForm } from "./lib.mjs";
46
+ import { readConfigAt, readOverlayAt, resolveAgentModel, writeOverlayAt, overlayId, layoutRoster, commandForm, projectDirRefusal } from "./lib.mjs";
47
47
  import { READ_OPERATIONS, LINEAGE_KINDS, LINEAGE_DEFAULT_DEPTH, SEARCH_DEFAULT_LIMIT, GRAPH_EDGE_CAP, DIRECTIONS } from "./query.mjs";
48
48
  // binding.mjs is a write module imported statically where the install family
49
49
  // is lazy: it is a dependency-free leaf with no side effects, so the MCP
@@ -554,7 +554,10 @@ async function runAgents(ctx) {
554
554
  if (inBinding) lines.push(" the binding still carries an agents block — a pre-0.28 leftover; run upgrade");
555
555
  return emit("agents show", true, { harness, path: before.path, present: before.present, unparseable: before.unparseable, agents: before.agents, resolved, roster, unknown, rejected: before.rejected, agents_in_binding: inBinding }, lines.join("\n") + "\n");
556
556
  }
557
- // configure
557
+ // configure — in a project that exists (the install spec, contract 19;
558
+ // the layout spec, contract 4): refused before the preview and the gate.
559
+ const missingProject = projectDirRefusal(project);
560
+ if (missingProject) return emit("agents configure", false, { error: missingProject }, `${missingProject}\n`);
558
561
  if (!harness) return usage("no harness detected and none named: agents configure names --harness <id>");
559
562
  if (before.unparseable) return emit("agents configure", false, { error: `${before.path} is not valid JSON; fix or remove it first`, harness, path: before.path }, `${before.path} is not valid JSON; fix or remove it first\n`);
560
563
  if (!values.reset && values.default === undefined && !(values.agent || []).length) return usage("agents configure takes --default <model>, --agent <name>=<model> (repeatable) or --reset");
@@ -69,7 +69,7 @@ import { loadHarness, loadHarnesses, harnessIds, sourceHarness, detectHarnesses,
69
69
  import { FOREIGN_TEXT, GRAMMAR_VERSION } from "./provenance.mjs";
70
70
  import { analyseBlock, analyseJsonEntry, analyseStampedFile, analyseRegistration, analysePortableRegistration, analyseLayout, isOurFile, readText } from "./surfaces.mjs";
71
71
  import { payloadFiles, renderPortableCatalog } from "./portable-registration.mjs";
72
- 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";
72
+ 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, projectDirRefusal } from "./lib.mjs";
73
73
 
74
74
  import { GENERATOR } from "./surfaces.mjs";
75
75
  export { GENERATOR };
@@ -373,6 +373,15 @@ function planPortableRegistration(ctx, key, s) {
373
373
  if (!a.market) steps.push(hostStep(a, s, "marketplace_add", fill, "register the stable local marketplace source globally"));
374
374
  if (!a.installed || a.installedVersion !== a.desiredVersion || mustWrite) steps.push(hostStep(a, s, "install", fill, `materialise and enable ${a.id} from the staged source`));
375
375
  steps.push(hostStep(a, s, "list", fill, "read back host-reported installation and effective enablement"));
376
+ // Asked first, before anything changes: the command the registration's own
377
+ // verification needs. A host too old to answer it stops the run with nothing
378
+ // staged, journaled or registered (issue #28: a codex without `plugin list
379
+ // --json` failed only at verification, after it had added the marketplace
380
+ // and the plugin; the install spec's 2026-09-29 amendment, as amended
381
+ // 2026-10-05).
382
+ if (steps.some((x) => x.kind === "portable-write" || (x.kind === "host" && x.name !== "list"))) {
383
+ steps.unshift({ ...hostStep(a, s, "list", fill, "check that the host can report what it installs, before anything changes"), name: "preflight" });
384
+ }
376
385
  return [{ ...base, action: a.state === "absent" ? "create" : "update", steps, verify: { version: a.desiredVersion, digest }, reason: a.reason }];
377
386
  }
378
387
 
@@ -590,6 +599,16 @@ export function plan(projectDir, { harnesses = [], mode = "install", env = proce
590
599
  out.ok = false;
591
600
  return out;
592
601
  }
602
+ // A project that does not exist is refused before contract 8's detection
603
+ // could stand in for it ("no harness detected"), and before anything is
604
+ // written, staged, locked or spawned (contract 19).
605
+ const missing = projectDirRefusal(projectDir);
606
+ if (missing) {
607
+ out.harnesses = named;
608
+ out.refusals.push(missing);
609
+ out.ok = false;
610
+ return out;
611
+ }
593
612
  if (!ids.length) {
594
613
  out.refusals.push(harnessRefusal(projectDir));
595
614
  out.ok = false;
@@ -1118,7 +1137,7 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1118
1137
  const r = spawn(bin || s.cli.bin, argv, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
1119
1138
  const rows = !r.error && r.status === 0 ? portableListFacts(r.stdout) : null;
1120
1139
  out.steps.push({ kind: "portable-recovery-list", argv: [s.cli.bin, ...argv], status: r.status ?? null, ok: Boolean(rows) });
1121
- return { rows, stderr: String((r.stderr || "") + (r.error ? r.error.message : "")).trim() };
1140
+ return { rows, stderr: r.error ? startFailure(r.error, { bin: s.cli.bin, resolved: bin, cwd: p.projectDir }) : String(r.stderr || "").trim() };
1122
1141
  };
1123
1142
  const observedPortable = (a) => ({
1124
1143
  dir_exists: existsSync(a.paths.dir),
@@ -1142,7 +1161,7 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1142
1161
  };
1143
1162
  const verifyPrevious = (previous) => {
1144
1163
  const listed = readHostList();
1145
- if (!listed.rows) return { ok: false, why: listed.stderr || "Codex plugin list did not return JSON" };
1164
+ if (!listed.rows) return { ok: false, unanswered: true, why: listed.stderr || "Codex plugin list did not return JSON" };
1146
1165
  const row = listed.rows.find((entry) => entry?.pluginId === i.entry) || null;
1147
1166
  if (!previous) {
1148
1167
  const a = analysePortableRegistration(p.projectDir, s, { root: p.root, home, harness, env: childEnv, ignoreJournal: true });
@@ -1171,10 +1190,21 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1171
1190
  const ok = a.state === "current" && a.installedVersion === previous.version && a.enabled === previous.enabled && sourceOk && cacheOk && hostOk;
1172
1191
  return ok ? { ok: true } : { ok: false, why: `restored source/cache/enablement could not be proven (state=${a.state}, version=${a.installedVersion || "?"}, enabled=${String(a.enabled)})` };
1173
1192
  };
1193
+ // A journal keeps its history (issue #28): the first failure's error and
1194
+ // time stay as written; a later run that re-checks and still cannot prove
1195
+ // the previous state adds its own reason and time beside them. The message
1196
+ // says the error is a previous run's, where the journal is, and what lifts it.
1174
1197
  const requireRecovery = (journal, why) => {
1175
- writeFileAtomic(journalPath, JSON.stringify({ ...journal, phase: "recovery-required", error: why }, null, 2) + "\n", { sweep: false });
1198
+ const now = new Date().toISOString();
1199
+ const again = journal.phase === "recovery-required";
1200
+ const record = again
1201
+ ? { ...journal, rechecked_at: now, recheck_error: why }
1202
+ : { ...journal, phase: "recovery-required", error: why, failed_at: now };
1203
+ writeFileAtomic(journalPath, JSON.stringify(record, null, 2) + "\n", { sweep: false });
1176
1204
  releaseLock();
1177
- out.failed = { step: "recovery-required", status: null, stderr: `${why}; ${journalPath} was retained and blocks automatic mutation` };
1205
+ out.failed = { step: "recovery-required", status: null, stderr: again
1206
+ ? `a previous run (${journal.failed_at || "time not recorded"}) could not prove its restore: ${String(journal.error || "no error recorded").split("\n")[0]}. This run re-checked the previous state and could not prove it either: ${why}. ${journalPath} is kept: a later run lifts it once the previous state proves, or inspect the registration and remove the journal to start over`
1207
+ : `${why}; ${journalPath} was retained and blocks automatic mutation until a later run proves the previous state` };
1178
1208
  return out;
1179
1209
  };
1180
1210
  if (portable) {
@@ -1219,22 +1249,43 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1219
1249
  releaseLock(); out.failed = { step: "recovery", status: null, stderr: `${journalPath} does not describe this owned marketplace; inspect it before retrying` }; return out;
1220
1250
  }
1221
1251
  try {
1222
- if (journal.phase === "recovery-required") return requireRecovery(journal, journal.error || "a prior registration could not prove restoration");
1223
- if (journal.phase === "verified") finishPortableMarketplace(journal.target, journal.backup || null);
1252
+ // A recovery the rollback could not prove is re-proved here, under the
1253
+ // lock, with the rollback's own checks: it lifts when they hold, and is
1254
+ // refused with its history kept when they do not (the install spec's
1255
+ // 2026-09-29 amendment, as amended 2026-10-05).
1256
+ let lifted = null;
1257
+ if (journal.phase === "recovery-required") {
1258
+ const proven = verifyPrevious(journal.previous || null);
1259
+ // A host that cannot answer the proof's read-back is the case issue
1260
+ // #28 met with a journal still in place: say what to do about it.
1261
+ if (!proven.ok) return requireRecovery(journal, proven.unanswered
1262
+ ? `${proven.why}\nThe re-check could not ask ${harness.display_name || s.cli.bin} (\`${[s.cli.bin, ...(s.cli.commands.list || [])].join(" ")}\`); if it is too old to answer, upgrade it, then run this again.`
1263
+ : proven.why);
1264
+ lifted = journal;
1265
+ } else if (journal.phase === "verified") finishPortableMarketplace(journal.target, journal.backup || null);
1224
1266
  else if (journal.phase === "swapped" || (journal.phase === "prepare" && journal.backup && existsSync(journal.backup))) rollbackPortableMarketplace(journal.target, journal.backup || null);
1225
1267
  else if (journal.phase === "prepare" && !journal.previous && existsSync(journal.target)) {
1226
1268
  if (!ownedTargetMatches(journal.target, journal.next)) return requireRecovery(journal, `first-install recovery found an unrecognised target at ${journal.target}`);
1227
1269
  rollbackPortableMarketplace(journal.target, null);
1228
1270
  }
1229
- if (existsSync(journal.stage)) removeTreeUnder(journal.stage, i.home);
1230
- if (journal.phase !== "verified") {
1271
+ if (existsSync(journal.stage) && resolve(journal.stage) !== resolve(i.path)) removeTreeUnder(journal.stage, i.home);
1272
+ if (journal.phase !== "verified" && !lifted) {
1231
1273
  const restored = verifyPrevious(journal.previous || null);
1232
1274
  if (!restored.ok) return requireRecovery(journal, restored.why);
1233
1275
  }
1234
1276
  unlinkSync(journalPath);
1235
- out.steps.push({ kind: "portable-recover", phase: journal.phase || "unknown", ok: true });
1277
+ out.steps.push({ kind: "portable-recover", phase: journal.phase || "unknown", ok: true, ...(lifted ? { lifted: lifted.error || null, failed_at: lifted.failed_at || null } : {}) });
1278
+ // Only a lifted recovery prints a line of its own: the other phases'
1279
+ // APPLY reads as it did. After the proof, whose host list has its line.
1280
+ if (lifted && onStep) {
1281
+ const st = { kind: "recover", path: journalPath, lifted: lifted.error || "" };
1282
+ onStep(st, "start");
1283
+ onStep(st, "end", { ok: true });
1284
+ }
1236
1285
  } catch (e) {
1237
- releaseLock(); out.failed = { step: "recovery", status: null, stderr: e && e.message ? e.message : String(e) }; return out;
1286
+ releaseLock();
1287
+ out.failed = { step: "recovery", status: null, stderr: `${e && e.message ? e.message : String(e)}; ${journalPath} is kept: a later run retries it, or inspect the registration and remove the journal to start over` };
1288
+ return out;
1238
1289
  }
1239
1290
  }
1240
1291
  const current = analysePortableRegistration(p.projectDir, s, { root: p.root, home, harness, env: childEnv, ignoreJournal: true });
@@ -1281,7 +1332,8 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1281
1332
  const args = template.map((t) => t.replace(/\{(\w+)\}/g, (_, k) => fill[k] ?? `{${k}}`));
1282
1333
  const bin = whichOnPathFromLib(s.cli.bin, env);
1283
1334
  const r = spawn(bin || s.cli.bin, args, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
1284
- out.steps.push({ kind: "portable-compensate", name, argv: [s.cli.bin, ...args], status: r.status ?? null, ok: !r.error && r.status === 0 });
1335
+ const ok = !r.error && r.status === 0;
1336
+ out.steps.push({ kind: "portable-compensate", name, argv: [s.cli.bin, ...args], status: r.status ?? null, ok, ...(ok ? {} : { stderr: r.error ? startFailure(r.error, { bin: s.cli.bin, resolved: bin, cwd: p.projectDir }) : String((r.stderr || "") + (r.stdout || "")).trim() }) });
1285
1337
  }
1286
1338
  }
1287
1339
  try {
@@ -1351,10 +1403,27 @@ function applyRegistration(p, i, { env, spawn, home, onStep = null }) {
1351
1403
  } else if (st.kind === "host") {
1352
1404
  const bin = whichOnPathFromLib(st.bin, env);
1353
1405
  const r = spawn(bin || st.bin, st.argv, { env: childEnv, cwd: p.projectDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
1354
- const ok = !r.error && r.status === 0;
1355
- const said = String((r.stderr || "") + (ok ? "" : r.stdout || "") + (r.error ? r.error.message : "")).trim();
1356
- out.steps.push({ kind: "host", argv: [st.bin, ...st.argv], status: r.status ?? null, ok, ...(ok ? {} : { stderr: said }) });
1357
- if (!ok) return fail(st.name, r.status ?? null, said, [st.bin, ...st.argv]);
1406
+ const ran = !r.error && r.status === 0;
1407
+ // A preflight that ran must also answer in the shape verification reads:
1408
+ // a host that exits 0 with text would pass on its status alone.
1409
+ const unreadable = ran && portable && st.name === "preflight" && portableListFacts(r.stdout) === null;
1410
+ const ok = ran && !unreadable;
1411
+ // A command that did not start says why (contract 19); one that ran and
1412
+ // failed says what it said. A preflight the host ran and could not
1413
+ // answer adds what that means; one that never started does not, since
1414
+ // upgrading the host would not help.
1415
+ const said = r.error
1416
+ ? startFailure(r.error, { bin: st.bin, resolved: bin, cwd: p.projectDir })
1417
+ : unreadable
1418
+ ? `${[st.bin, ...st.argv].join(" ")} answered, but not with the JSON the registration reads`
1419
+ : String((r.stderr || "") + (ok ? "" : r.stdout || "")).trim();
1420
+ const told = !ok && st.name === "preflight" && !r.error
1421
+ ? `${said}\nThe registration needs \`${[st.bin, ...st.argv].join(" ")}\` to verify what it installs, so it changed nothing. Upgrade ${harness.display_name || st.bin}, then run this again.`
1422
+ : said;
1423
+ out.steps.push({ kind: "host", argv: [st.bin, ...st.argv], status: r.status ?? null, ok, ...(ok ? {} : { stderr: told }) });
1424
+ // An unreadable answer exited 0; STOPPED names it by what it said, as
1425
+ // the final list's own failure does, not by an "exited 0".
1426
+ if (!ok) return fail(st.name, unreadable ? null : r.status ?? null, told, [st.bin, ...st.argv]);
1358
1427
  if (portable && st.name === "list") {
1359
1428
  hostList = portableListFact(r.stdout, i.entry);
1360
1429
  if (!hostList) return fail("list", null, `${st.bin} ${st.argv.join(" ")} did not report ${i.entry} in JSON output`, [st.bin, ...st.argv]);
@@ -1403,6 +1472,19 @@ function homeEnvName(harnessId) {
1403
1472
  try { return loadHarness(harnessId).runtime.home_env; } catch { return sourceHarness().runtime.home_env; }
1404
1473
  }
1405
1474
 
1475
+ // What Node cannot say: spawnSync reports a working directory that does not
1476
+ // exist and a binary not on PATH with the same `spawnSync <bin> ENOENT`
1477
+ // (measured 2026-10-05). The filesystem tells them apart, in this order (the
1478
+ // install spec, contract 19); any other error passes through as Node wrote it.
1479
+ function startFailure(err, { bin, resolved, cwd }) {
1480
+ let dir = false;
1481
+ try { dir = statSync(cwd).isDirectory(); } catch {}
1482
+ if (!dir) return `${bin} could not start: the working directory ${JSON.stringify(String(cwd))} is not an existing directory`;
1483
+ if (!resolved) return `${bin} could not start: it was not found on PATH`;
1484
+ if (["ENOENT", "EACCES", "ENOEXEC"].includes(err?.code)) return `${bin} could not start: ${resolved} exists but could not be run (${err.code})`;
1485
+ return String(err?.message || err);
1486
+ }
1487
+
1406
1488
  // rmdirSync refuses a non-empty directory — that refusal IS the guarantee
1407
1489
  // (contract 13): we prune only a directory we emptied. The runtime dir's own
1408
1490
  // .gitignore does not count as content.
@@ -1499,6 +1581,7 @@ function stepLabel(p, st) {
1499
1581
  const r = (x) => tilde(rel(p.projectDir, x));
1500
1582
  switch (st.kind) {
1501
1583
  case "portable-write": return `stage ${r(st.path)}`;
1584
+ case "recover": return `lift ${r(st.path)} — the previous state proved${st.lifted ? `; it held: ${String(st.lifted).split("\n")[0]}` : ""}`;
1502
1585
  case "write": return `write ${r(st.path)}`;
1503
1586
  case "unregister": return `edit ${r(st.path)} [${st.pointer}.${st.name}]`;
1504
1587
  case "ensure": return `ensure ${r(st.path)}/`;
@@ -689,6 +689,18 @@ export function installChannel(root, { home = homedir(), harness = sourceHarness
689
689
  return own && marketplace === own.marketplace_name ? "registration" : "marketplace";
690
690
  }
691
691
 
692
+ // A write verb works in a project that exists (the install spec, contract 19):
693
+ // install, upgrade, uninstall and plan, and agents configure, refuse a path
694
+ // that is not an existing directory before anything is written, staged or
695
+ // spawned. The path is quoted, so a trailing comma, space or newline shows: a
696
+ // `--project "$PWD",` pasted from a chat once created `ProjectStore,/` and
697
+ // wrote an AGENTS.md into it (2026-10-05). bind refuses the same way
698
+ // (NO_PROJECT, binding.mjs).
699
+ export function projectDirRefusal(dir) {
700
+ try { if (statSync(dir).isDirectory()) return null; } catch {}
701
+ return `no such project directory: ${JSON.stringify(String(dir))} — nothing is written`;
702
+ }
703
+
692
704
  // ─── Status line wiring (SessionStart-managed) ─────────────────────────
693
705
  //
694
706
  // The Claude Code statusLine slot is single and NOT plugin-declarable, so
@@ -179,7 +179,12 @@ export function analysePortableRegistration(projectDir, s, { root, payloadRoot:
179
179
  try { desiredDigest = payloadDigest(payloadRoot); } catch (e) { return { ...out, state: "unavailable", reason: e.message }; }
180
180
  out.desiredDigest = desiredDigest;
181
181
  if (enabledOther) return { ...out, state: "conflict", refusal: `${enabledOther.id} is already enabled; two ProjectStore plugins would load the same skills and hooks. Disable or remove that registration explicitly, then retry` };
182
- if (journal && !ignoreJournal) return { ...out, state: "stale", reason: "an interrupted registration has a recovery journal; the next confirmed run recovers it before applying this plan" };
182
+ // A journal the rollback could not prove names the run that left it, when,
183
+ // and its error, so the reader of the plan is not told recovery is assured
184
+ // (issue #28). Every other phase reads as before.
185
+ if (journal && !ignoreJournal) return { ...out, state: "stale", reason: journal.phase === "recovery-required"
186
+ ? `a previous run${journal.failed_at ? ` (${journal.failed_at})` : ""} could not prove its restore: ${String(journal.error || "no error recorded").split("\n")[0]}; this run re-proves the previous state before applying this plan, and refuses if it cannot (${paths.journal})`
187
+ : "an interrupted registration has a recovery journal; the next confirmed run recovers it before applying this plan" };
183
188
  if (existsSync(paths.dir) && !ownership) return { ...out, state: "foreign", refusal: `${paths.dir} exists without ${s.ownership_manifest}; a source this installer does not own holds the stable marketplace path` };
184
189
  if (market?.source && !samePath(market.source, paths.dir)) return { ...out, state: "foreign", refusal: `${paths.globalConfig} points marketplace ${s.marketplace_name} at ${market.source}, not ${paths.dir}` };
185
190
  if (nothing) return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projectstore-claude",
3
- "version": "0.29.1",
3
+ "version": "0.29.2",
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.29.1"
40
+ "projectstore": "=0.29.2"
41
41
  },
42
42
  "bundleDependencies": [
43
43
  "projectstore"