projectstore-claude 0.29.0 → 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.
Files changed (32) hide show
  1. package/README.md +1 -0
  2. package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
  3. package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
  4. package/node_modules/projectstore/README.md +10 -2
  5. package/node_modules/projectstore/commands/review.md +1 -1
  6. package/node_modules/projectstore/harnesses/claude-code.json +17 -2
  7. package/node_modules/projectstore/harnesses/codex.json +20 -4
  8. package/node_modules/projectstore/hooks/pre-compact.mjs +2 -1
  9. package/node_modules/projectstore/hooks/session-rules.mjs +19 -7
  10. package/node_modules/projectstore/hooks/session-start.mjs +20 -18
  11. package/node_modules/projectstore/package.json +2 -2
  12. package/node_modules/projectstore/scripts/binding.mjs +3 -3
  13. package/node_modules/projectstore/scripts/build-adapters.mjs +43 -13
  14. package/node_modules/projectstore/scripts/cli.mjs +7 -4
  15. package/node_modules/projectstore/scripts/codemap.mjs +2 -2
  16. package/node_modules/projectstore/scripts/doctor.mjs +110 -58
  17. package/node_modules/projectstore/scripts/draft.mjs +4 -3
  18. package/node_modules/projectstore/scripts/graph.mjs +3 -2
  19. package/node_modules/projectstore/scripts/harness.mjs +89 -0
  20. package/node_modules/projectstore/scripts/install-harness.mjs +99 -16
  21. package/node_modules/projectstore/scripts/kanban.mjs +3 -2
  22. package/node_modules/projectstore/scripts/lib.mjs +81 -8
  23. package/node_modules/projectstore/scripts/mcp.mjs +2 -2
  24. package/node_modules/projectstore/scripts/portable-registration.mjs +6 -1
  25. package/node_modules/projectstore/scripts/query.mjs +2 -1
  26. package/node_modules/projectstore/scripts/reconcile.mjs +4 -3
  27. package/node_modules/projectstore/scripts/story-section.mjs +2 -1
  28. package/node_modules/projectstore/scripts/surfaces.mjs +2 -1
  29. package/node_modules/projectstore/scripts/term.mjs +3 -1
  30. package/node_modules/projectstore/scripts/touch-session.mjs +6 -1
  31. package/node_modules/projectstore/scripts/worktree.mjs +12 -7
  32. package/package.json +2 -2
@@ -217,6 +217,8 @@ function ranked(env, dir) {
217
217
  // process.env to impersonate a harness needs the next call to look again.
218
218
  export function resetDetection() {
219
219
  _detected = null;
220
+ _speaking = null;
221
+ _bundling = undefined;
220
222
  }
221
223
 
222
224
  // Which harnesses this PROJECT uses — by directory (install spec, contract
@@ -247,6 +249,93 @@ export function activeHarness(env = process.env, dir = MANIFEST_DIR) {
247
249
  return loadHarness(detectHarnessId(env, dir), dir) || sourceHarness(dir);
248
250
  }
249
251
 
252
+ // ─── Who a message is for (generation spec, contract 18) ───────────────
253
+ //
254
+ // The core prints for ONE harness at a time, and a command it names must be
255
+ // one that harness can run: `/projectstore:doctor` in Claude Code,
256
+ // `$projectstore-doctor` in Codex, where commands arrive as rendered skills.
257
+ // The form is manifest data; these functions only fill it in.
258
+
259
+ // How a harness calls one of our commands (kind "commands") or roles (kind
260
+ // "agents"): the surface's own `invocation` template, or — for a surface the
261
+ // generator renders as skills — its `rendered_name` filled into the skills'
262
+ // template. `*` names the family ("/projectstore:*"). Filled by split/join, so
263
+ // a `$` in a template is never read as a replacement pattern.
264
+ export function invocation(manifest, name, { kind = "commands", args = "" } = {}) {
265
+ // No manifest (a partial tarball): the bare name, never a throw in a hook.
266
+ const surfaces = manifest?.surfaces || {};
267
+ const s = surfaces[kind] || {};
268
+ let template = s.invocation || null;
269
+ if (!template && s.rendered_as === "skill" && s.rendered_name && surfaces.skills?.invocation) {
270
+ template = surfaces.skills.invocation.split("<name>").join(s.rendered_name);
271
+ }
272
+ const form = template ? template.split("<name>").join(name) : name;
273
+ return args ? `${form} ${args}` : form;
274
+ }
275
+
276
+ // The UI words a manifest declares (ui_vocabulary), as patterns. A word glued
277
+ // to a longer name or to a path is not the word: `/plugin-x` is not `/plugin`,
278
+ // and neither is the one in `.claude-plugin/plugin.json`; a full stop after it
279
+ // still ends a sentence. The generator's leak check and the vocabulary lint
280
+ // read these same patterns.
281
+ export function uiWordPatterns(manifest) {
282
+ const esc = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
283
+ return (manifest?.ui_vocabulary || []).map((word) => ({ word, re: new RegExp(`(?<![\\w.-])${esc(word)}(?![\\w-]|\\.\\w)`, "g") }));
284
+ }
285
+
286
+ // The harness whose distribution shell bundles this core, by the shell's
287
+ // package name: a shell installs the core at <shell>/node_modules/projectstore,
288
+ // and its package.json names it as a manifest's install.shell. A hook, a
289
+ // rendered skill's command and an npx run inside that tree all belong to that
290
+ // harness by construction — nothing to export, nothing a model can forget.
291
+ // A source checkout or a host's own cache of the core answers null.
292
+ let _bundling;
293
+ export function bundlingShellHarnessId({ core = REPO_ROOT, dir = MANIFEST_DIR } = {}) {
294
+ const memo = core === REPO_ROOT && dir === MANIFEST_DIR;
295
+ if (memo && _bundling !== undefined) return _bundling;
296
+ let id = null;
297
+ try {
298
+ const real = realOr(core);
299
+ const parent = dirname(real);
300
+ if (parent.split(sep).pop() === "node_modules") {
301
+ const name = JSON.parse(readFileSync(join(dirname(parent), "package.json"), "utf8")).name;
302
+ id = [...loadHarnesses(dir).values()].find((m) => m.install?.shell && m.install.shell === name)?.id ?? null;
303
+ }
304
+ } catch { id = null; }
305
+ if (memo) _bundling = id;
306
+ return id;
307
+ }
308
+
309
+ // The harness a message is FOR: forced (PROJECTSTORE_HARNESS); else the one
310
+ // that identifies itself — a strong signal or its session marker; else the
311
+ // shell that bundles this core; else the shell the environment names
312
+ // (PROJECTSTORE_SHELL, which only the shells set); else the source harness.
313
+ // `--harness <id>` names an install target and plays no part in this.
314
+ let _speaking = null;
315
+ export function speakingHarnessId(env = process.env, dir = MANIFEST_DIR) {
316
+ const memo = env === process.env && dir === MANIFEST_DIR;
317
+ if (memo && _speaking) return _speaking;
318
+ const id = speaker(env, dir);
319
+ if (memo) _speaking = id;
320
+ return id;
321
+ }
322
+
323
+ function speaker(env, dir) {
324
+ const identified = identifiedHarnessId(env, dir);
325
+ if (identified) return identified;
326
+ const bundled = bundlingShellHarnessId({ dir });
327
+ if (bundled) return bundled;
328
+ if (env.PROJECTSTORE_SHELL) {
329
+ const named = [...loadHarnesses(dir).values()].find((m) => m.install?.shell === env.PROJECTSTORE_SHELL);
330
+ if (named) return named.id;
331
+ }
332
+ return sourceHarness(dir)?.id ?? null;
333
+ }
334
+
335
+ export function speakingHarness(env = process.env, dir = MANIFEST_DIR) {
336
+ return loadHarness(speakingHarnessId(env, dir), dir) || sourceHarness(dir);
337
+ }
338
+
250
339
  // ─── Runtime names and paths ───────────────────────────────────────────
251
340
 
252
341
  // The three branded names the active harness uses, so a caller can say which
@@ -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)}/`;
@@ -21,7 +21,8 @@ import {
21
21
  listEpicStories,
22
22
  slugIdentity,
23
23
  displayNumberOf,
24
- compareArtifactOrder, isMain
24
+ compareArtifactOrder, isMain,
25
+ commandForm,
25
26
  } from "./lib.mjs";
26
27
 
27
28
  function die(msg) {
@@ -117,7 +118,7 @@ function renderItem(story) {
117
118
 
118
119
  function main() {
119
120
  const cfg = readConfig();
120
- if (!cfg) die("No projectstore config. Run /projectstore:bind first.");
121
+ if (!cfg) die(`No projectstore config. Run ${commandForm("bind")} first.`);
121
122
  const layout = loadLayout(cfg.layout);
122
123
  if (!layout.kanban) die(`Layout ${cfg.layout} does not declare a kanban config.`);
123
124
 
@@ -28,6 +28,9 @@ import {
28
28
  isWriteTool as harnessIsWriteTool,
29
29
  toolPaths as harnessToolPaths,
30
30
  sourceHarness,
31
+ invocation,
32
+ speakingHarness,
33
+ packageCommand,
31
34
  } from "./harness.mjs";
32
35
 
33
36
  // ─── Paths ─────────────────────────────────────────────────────────────
@@ -47,10 +50,63 @@ export function configPath() {
47
50
  return harnessConfigPath(projectRoot(), process.env);
48
51
  }
49
52
 
53
+ // ─── What a message names (generation spec, contract 18) ───────────────
54
+ //
55
+ // Every command, role and update path a message names, in the form the
56
+ // harness it is printed for can use: `/projectstore:doctor` in Claude Code,
57
+ // `$projectstore-doctor` in Codex. A name passed here must be a string literal
58
+ // — tests/portability.test.mjs resolves each one on every harness, so a
59
+ // command renamed on one harness fails where another would name it.
60
+ export function commandForm(name, { args = "", env = process.env } = {}) {
61
+ return invocation(speakingHarness(env), name, { kind: "commands", args });
62
+ }
63
+ export function roleForm(name, { env = process.env } = {}) {
64
+ return invocation(speakingHarness(env), name, { kind: "agents" });
65
+ }
66
+ // The form the shared AGENTS.md block uses: the source harness's, until the
67
+ // block is rendered per harness.
68
+ export function sharedRoleForm(name) {
69
+ return invocation(sourceHarness(), name, { kind: "agents" });
70
+ }
71
+ // What a welcome says about updates, and the product name a message uses.
72
+ export function updateHint({ env = process.env } = {}) {
73
+ const m = speakingHarness(env) || {};
74
+ // A harness that declares no hint gets none — never another harness's advice.
75
+ const hint = m.update_hint || { line: "", welcome: [] };
76
+ // `{upgrade}` is the harness's own shell command, pinned to latest so it runs as printed.
77
+ const upgrade = m.install?.shell ? packageCommand(m, "upgrade", { version: "latest", args: '--project "$PWD"' }) : "";
78
+ const fill = (t) => String(t).split("{upgrade}").join(upgrade);
79
+ return { line: fill(hint.line || ""), welcome: (hint.welcome || []).map(fill) };
80
+ }
81
+ // How an unbound worktree adopts its parent's binding: `bind --inherit` where
82
+ // the harness's bind can inherit, else bind with the parent's vault named.
83
+ // Whether the listening harness's bind can adopt a parent checkout's binding
84
+ // whole (capabilities.bind_inherit). Where it cannot, a message says what its
85
+ // bind does instead: the same vault, layout and language, not the binding.
86
+ export function bindInherits({ env = process.env } = {}) {
87
+ return Boolean(speakingHarness(env)?.capabilities?.bind_inherit);
88
+ }
89
+
90
+ // Where it cannot inherit, the parent's vault, layout and language travel in
91
+ // the form, so the new binding has those three of the parent's; every other
92
+ // key is bind's default. The flags are the core verb's (`--layout`,
93
+ // `--language`): a harness whose bind cannot inherit renders a bind skill that
94
+ // hands them to the verb, and lint layer 2 holds that skill to naming each
95
+ // one. A value that is not a plain name is left out, never pasted raw.
96
+ export function inheritForm(vaultPath, { layout = null, language = null, env = process.env } = {}) {
97
+ if (speakingHarness(env)?.capabilities?.bind_inherit) return commandForm("bind", { args: "--inherit", env });
98
+ const plain = (v) => typeof v === "string" && /^[a-z0-9-]+$/.test(v);
99
+ const args = [JSON.stringify(String(vaultPath)), plain(layout) ? `--layout ${layout}` : "", plain(language) ? `--language ${language}` : ""].filter(Boolean).join(" ");
100
+ return commandForm("bind", { args, env });
101
+ }
102
+ export function speakingDisplayName({ env = process.env } = {}) {
103
+ return speakingHarness(env)?.display_name || "another";
104
+ }
105
+
50
106
  // The layout resolver and its constants, re-exported so hooks and scripts
51
107
  // import one module (the layout ADR, 2026-09-06). The active harness's id,
52
108
  // for the paths keyed by it (state/<id>/…).
53
- export { layoutPaths, pickExisting, LAYOUT, RUNTIME_GITIGNORE_HEADER, hostSettingsPath, overlayId };
109
+ export { layoutPaths, pickExisting, LAYOUT, RUNTIME_GITIGNORE_HEADER, hostSettingsPath, overlayId, speakingHarness };
54
110
 
55
111
  // A hook's payload carries the `cwd` of the session that fired it, and on a
56
112
  // harness that exports no project-dir variable it is the only answer better
@@ -633,6 +689,18 @@ export function installChannel(root, { home = homedir(), harness = sourceHarness
633
689
  return own && marketplace === own.marketplace_name ? "registration" : "marketplace";
634
690
  }
635
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
+
636
704
  // ─── Status line wiring (SessionStart-managed) ─────────────────────────
637
705
  //
638
706
  // The Claude Code statusLine slot is single and NOT plugin-declarable, so
@@ -1997,7 +2065,7 @@ export function renderVaultSkeleton(facts) {
1997
2065
  if (cont.status === "timeout") {
1998
2066
  L.push("## Where this session left off");
1999
2067
  L.push("");
2000
- L.push("- recent activity not resolved within budget — run `/projectstore:status`");
2068
+ L.push(`- recent activity not resolved within budget — run \`${commandForm("status")}\``);
2001
2069
  L.push("");
2002
2070
  } else if (cont.paths && cont.paths.length > 0) {
2003
2071
  L.push("## Where this session left off");
@@ -2006,7 +2074,7 @@ export function renderVaultSkeleton(facts) {
2006
2074
  L.push("");
2007
2075
  for (const p of cont.paths.slice(0, INFLIGHT_CAP)) L.push(`- ${pathCell(p)}`);
2008
2076
  const more = (cont.total ?? cont.paths.length) - Math.min(cont.paths.length, INFLIGHT_CAP);
2009
- if (more > 0) L.push(`- …and ${more} more; see \`/projectstore:status\``);
2077
+ if (more > 0) L.push(`- …and ${more} more; see \`${commandForm("status")}\``);
2010
2078
  if (cont.artifact) {
2011
2079
  L.push("");
2012
2080
  L.push(`**In flight**: ${pathCell(cont.artifact)} was the newest structured write before` +
@@ -2692,8 +2760,8 @@ export function entryReminderText(n) {
2692
2760
  return [
2693
2761
  `**projectstore**: this session has written to ${n} source files and no story`,
2694
2762
  "is in progress. If this is feature-sized work, open it in the vault before",
2695
- 'going further — `/projectstore:story <EPIC> "<title>"`, or',
2696
- "`/projectstore:epic` if it needs a new one. If it is a one-off fix, carry",
2763
+ `going further — \`${commandForm("story", { args: '<EPIC> "<title>"' })}\`, or`,
2764
+ `\`${commandForm("epic")}\` if it needs a new one. If it is a one-off fix, carry`,
2697
2765
  "on — this fires once.",
2698
2766
  ].join("\n");
2699
2767
  }
@@ -3056,11 +3124,16 @@ export function sessionNameOffer(name, { peers = [], current = null, declined =
3056
3124
  return null;
3057
3125
  }
3058
3126
 
3059
- export function sessionNameOfferText(offer) {
3127
+ // The command comes from the speaking harness's manifest (runtime.
3128
+ // session_rename); a harness without one is offered nothing.
3129
+ export function sessionNameOfferText(offer, { env = process.env } = {}) {
3060
3130
  if (!offer || !offer.name) return null;
3131
+ const rename = speakingHarness(env)?.capabilities?.session_rename;
3132
+ if (!rename) return null;
3133
+ const run = rename.split("<name>").join(offer.name);
3061
3134
  return offer.qualified
3062
- ? `projectstore: this session looks like "${offer.name}" (a peer holds the unqualified name) — /rename ${offer.name}`
3063
- : `projectstore: this session looks like "${offer.name}" — /rename ${offer.name}`;
3135
+ ? `projectstore: this session looks like "${offer.name}" (a peer holds the unqualified name) — ${run}`
3136
+ : `projectstore: this session looks like "${offer.name}" — ${run}`;
3064
3137
  }
3065
3138
 
3066
3139
  // ─── Frontmatter parsing (minimal) ─────────────────────────────────────
@@ -65,7 +65,7 @@ function spawnBin(argv, { env, cwd, timeoutMs = DOCTOR_TIMEOUT_MS, onChild = nul
65
65
  child.on("close", (code) => { clearTimeout(timer); done({ code: code === null ? 2 : code, out, err }); });
66
66
  });
67
67
  }
68
- import { readConfigAt, isInsideVault } from "./lib.mjs";
68
+ import { readConfigAt, isInsideVault, commandForm } from "./lib.mjs";
69
69
 
70
70
  // Two revisions, not four: 2025-03-26 has JSON-RPC batching (removed again in
71
71
  // 2025-06-18), which this server answers with -32600 — advertising that
@@ -247,7 +247,7 @@ export function createServer({ project, env = process.env, cwd = project, versio
247
247
  // answer the bind instruction inside the envelope.
248
248
  const rowRequiresBinding = (VERBS.find((v) => v.verb === t.verb) || {}).requiresBinding !== false;
249
249
  if (rowRequiresBinding && !cfgOf()) {
250
- return { content: [{ type: "text", text: JSON.stringify(envelope(t.verb, project, false, { error: `${project} is not bound to a vault — run /projectstore:bind <vault> in a session, or projectstore bind <vault> --project ${project}` })) }], isError: true };
250
+ return { content: [{ type: "text", text: JSON.stringify(envelope(t.verb, project, false, { error: `${project} is not bound to a vault — run ${commandForm("bind", { args: "<vault>", env })} in a session, or projectstore bind <vault> --project ${project}` })) }], isError: true };
251
251
  }
252
252
  const { opts, positionals } = t.argv(a);
253
253
  const argv = [t.verb, ...opts, "--json", "--project", project, ...(positionals.length ? ["--", ...positionals] : [])];
@@ -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;
@@ -49,6 +49,7 @@ import {
49
49
  listOf,
50
50
  sectionOf,
51
51
  isInsideVault,
52
+ commandForm,
52
53
  } from "./lib.mjs";
53
54
  import { walkVaultFiles } from "./doctor.mjs";
54
55
  import { buildGraph } from "./graph.mjs";
@@ -202,7 +203,7 @@ export function status(cfg, { project = null } = {}) {
202
203
  }
203
204
 
204
205
  export function renderStatus(r) {
205
- if (!r.bound) return `Not bound${r.project ? ` — ${r.project}` : ""}. Run /projectstore:bind <vault> in a session.\n`;
206
+ if (!r.bound) return `Not bound${r.project ? ` — ${r.project}` : ""}. Run ${commandForm("bind", { args: "<vault>" })} in a session.\n`;
206
207
  const lines = [`Vault: ${r.vault_path}${r.vault_exists ? "" : " (missing)"}`, `Layout: ${r.layout} · language: ${r.language} · auto_inject: ${r.auto_inject} · approval_mode: ${r.approval_mode} · spec_policy: ${r.spec_policy} · lifecycle_gates: ${r.lifecycle_gates}`];
207
208
  if (r.stories && r.stories.status !== "ok") lines.push(`Stories: not counted — ${r.stories.error}`);
208
209
  else if (r.stories) {
@@ -40,7 +40,8 @@ import {
40
40
  slugIdentity,
41
41
  displayNumberOf,
42
42
  compareArtifactOrder,
43
- writeFileAtomic, isMain
43
+ writeFileAtomic, isMain,
44
+ commandForm,
44
45
  } from "./lib.mjs";
45
46
  import { childEnv } from "./harness.mjs";
46
47
 
@@ -145,7 +146,7 @@ const SKIP = {
145
146
  : null,
146
147
  "graph.mjs": (g, onDisk) =>
147
148
  onDisk === null
148
- ? "graph.md does not exist yet — create it explicitly (--only graph or /projectstore:graph)"
149
+ ? `graph.md does not exist yet — create it explicitly (--only graph or ${commandForm("graph")})`
149
150
  : null,
150
151
  };
151
152
 
@@ -306,7 +307,7 @@ export function runReconcile({ write = false, only = null, projectDir = null, en
306
307
  _projectDir = projectDir;
307
308
  _env = env;
308
309
  const cfg = projectDir ? readConfigAt(projectDir) : readConfig();
309
- if (!cfg) { const e = new Error("No projectstore config. Run /projectstore:bind first."); e.code = "UNBOUND"; throw e; }
310
+ if (!cfg) { const e = new Error(`No projectstore config. Run ${commandForm("bind")} first.`); e.code = "UNBOUND"; throw e; }
310
311
  const layout = loadLayout(cfg.layout);
311
312
  const sel = resolveSelection(layout, only);
312
313
  if (sel.error) throw new Error(sel.error);
@@ -34,6 +34,7 @@ import {
34
34
  footerDateRe,
35
35
  nowIso,
36
36
  today,
37
+ commandForm,
37
38
  } from "./lib.mjs";
38
39
 
39
40
  function die(msg) {
@@ -116,7 +117,7 @@ function main() {
116
117
  die("usage: story-section.mjs <plan|close> <story-path> [--check <baseline-file>]");
117
118
  }
118
119
  const cfg = readConfig();
119
- if (!cfg) die("No projectstore config. Run /projectstore:bind first.");
120
+ if (!cfg) die(`No projectstore config. Run ${commandForm("bind")} first.`);
120
121
  const lang = cfg.language || "en";
121
122
  const abs = resolve(storyPath);
122
123
  if (!existsSync(abs)) die(`story not found: ${abs}`);
@@ -51,6 +51,7 @@ import {
51
51
  layoutPaths,
52
52
  RUNTIME_GITIGNORE_HEADER,
53
53
  LAUNCHER_HEADER,
54
+ commandForm,
54
55
  } from "./lib.mjs";
55
56
  import { analysePortableRegistration, portableRegistrationPaths, portablePayloadRoot } from "./portable-registration.mjs";
56
57
 
@@ -109,7 +110,7 @@ export function analyseBlock(projectDir, s, { root = pluginRoot(), manifestDir =
109
110
  const a = { files: found, own: files, withBlock, preferred, claude, importLine, PREFERRED, FALLBACK, entryKey: "projectstore:agents", version: null, desired: null, current: null, state: "ours-absent", reason: null, refusal: null };
110
111
 
111
112
  const unclosed = withBlock.find((e) => e.block.unclosed);
112
- if (unclosed && unclosed.block.wrapped) return { ...a, state: "unparseable", refusal: `${unclosed.file}:${unclosed.block.line}: the projectstore:agents open marker does not close on its own line — the parser reads one line. Put \`-->\` back on the marker's line, then run /projectstore:agents register` };
113
+ if (unclosed && unclosed.block.wrapped) return { ...a, state: "unparseable", refusal: `${unclosed.file}:${unclosed.block.line}: the projectstore:agents open marker does not close on its own line — the parser reads one line. Put \`-->\` back on the marker's line, then run ${commandForm("agents", { args: "register" })}` };
113
114
  if (unclosed) return { ...a, state: "unparseable", refusal: `${unclosed.file}: the block opens and never closes — restore the closing marker or remove the block by hand` };
114
115
  const twice = withBlock.find((e) => e.block.count > 1);
115
116
  if (twice) return { ...a, state: "unparseable", refusal: `${twice.file} carries the block more than once — keep exactly one` };
@@ -141,7 +141,9 @@ export async function askLine(question, input, output) {
141
141
  return await new Promise((settle) => {
142
142
  let done = false;
143
143
  const finish = (answer) => { if (done) return; done = true; settle(answer); rl.close(); };
144
- rl.on("close", () => finish(null));
144
+ // End of input echoes no newline: end the question's line, so whatever is
145
+ // printed next ("Nothing written.") starts on a line of its own.
146
+ rl.on("close", () => { if (!done) output.write("\n"); finish(null); });
145
147
  rl.question(question).then((a) => finish(a), () => finish(null));
146
148
  });
147
149
  }
@@ -71,6 +71,8 @@ import {
71
71
  sessionNameOffer,
72
72
  sessionNameOfferText,
73
73
  toolPaths,
74
+ commandForm,
75
+ speakingHarness,
74
76
  } from "./lib.mjs";
75
77
 
76
78
  const NUDGE_INTERVAL_MS = 10 * 60 * 1000;
@@ -96,6 +98,9 @@ function anchorOffer(cfg, proj, sid, filePath, toolName, isSubagent, sessionsDir
96
98
  // this session id and cannot accept a name — must not vote on one.
97
99
  if (!isWriteTool(toolName || "")) return null;
98
100
  if (isSubagent) return null;
101
+ // A harness with no way to rename a session gets no offer, and so keeps no
102
+ // tally and logs nothing for one (generation spec, contract 18).
103
+ if (!speakingHarness()?.capabilities?.session_rename) return null;
99
104
 
100
105
  const rel = filePath.slice(cfg.vault_path.length + 1);
101
106
  let hit = null;
@@ -191,7 +196,7 @@ function updatePointerAndNudge(cfg, proj, sid, filePath, toolName) {
191
196
  const last = st && st.nudged_at ? Date.parse(st.nudged_at) : 0;
192
197
  if (Date.now() - last > NUDGE_INTERVAL_MS) {
193
198
  nudge =
194
- "projectstore: vault file edited directly — if this bypassed a /projectstore:* command, run /projectstore:reconcile (or doctor) afterwards so the board/indexes stay in sync.";
199
+ `projectstore: vault file edited directly — if this bypassed a ${commandForm("*")} command, run ${commandForm("reconcile")} (or doctor) afterwards so the board/indexes stay in sync.`;
195
200
  patch = { ...(patch || {}), nudged_at: new Date().toISOString() };
196
201
  }
197
202
  }