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.
- 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/commands/review.md +1 -1
- package/node_modules/projectstore/harnesses/claude-code.json +17 -2
- package/node_modules/projectstore/harnesses/codex.json +20 -4
- package/node_modules/projectstore/hooks/pre-compact.mjs +2 -1
- package/node_modules/projectstore/hooks/session-rules.mjs +19 -7
- package/node_modules/projectstore/hooks/session-start.mjs +20 -18
- package/node_modules/projectstore/package.json +2 -2
- package/node_modules/projectstore/scripts/binding.mjs +3 -3
- package/node_modules/projectstore/scripts/build-adapters.mjs +43 -13
- package/node_modules/projectstore/scripts/cli.mjs +7 -4
- package/node_modules/projectstore/scripts/codemap.mjs +2 -2
- package/node_modules/projectstore/scripts/doctor.mjs +110 -58
- package/node_modules/projectstore/scripts/draft.mjs +4 -3
- package/node_modules/projectstore/scripts/graph.mjs +3 -2
- package/node_modules/projectstore/scripts/harness.mjs +89 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +99 -16
- package/node_modules/projectstore/scripts/kanban.mjs +3 -2
- package/node_modules/projectstore/scripts/lib.mjs +81 -8
- package/node_modules/projectstore/scripts/mcp.mjs +2 -2
- package/node_modules/projectstore/scripts/portable-registration.mjs +6 -1
- package/node_modules/projectstore/scripts/query.mjs +2 -1
- package/node_modules/projectstore/scripts/reconcile.mjs +4 -3
- package/node_modules/projectstore/scripts/story-section.mjs +2 -1
- package/node_modules/projectstore/scripts/surfaces.mjs +2 -1
- package/node_modules/projectstore/scripts/term.mjs +3 -1
- package/node_modules/projectstore/scripts/touch-session.mjs +6 -1
- package/node_modules/projectstore/scripts/worktree.mjs +12 -7
- 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:
|
|
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)}/`;
|
|
@@ -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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
2696
|
-
"
|
|
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
|
-
|
|
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) —
|
|
3063
|
-
: `projectstore: this session looks like "${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
|
|
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
|
-
|
|
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
|
|
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
|
-
?
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|