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 +1 -0
- 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 +10 -2
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/cli.mjs +5 -2
- package/node_modules/projectstore/scripts/install-harness.mjs +99 -16
- package/node_modules/projectstore/scripts/lib.mjs +12 -0
- package/node_modules/projectstore/scripts/portable-registration.mjs +6 -1
- package/package.json +2 -2
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1223
|
-
|
|
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();
|
|
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
|
-
|
|
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
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
40
|
+
"projectstore": "=0.29.2"
|
|
41
41
|
},
|
|
42
42
|
"bundleDependencies": [
|
|
43
43
|
"projectstore"
|