projectstore-claude 0.30.3 → 0.30.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +1 -1
- package/node_modules/projectstore/commands/agents.md +5 -2
- package/node_modules/projectstore/commands/doctor.md +4 -0
- package/node_modules/projectstore/docs/how-it-works.md +7 -0
- package/node_modules/projectstore/harnesses/claude-code.json +3 -2
- package/node_modules/projectstore/harnesses/codex.json +45 -15
- package/node_modules/projectstore/hooks/band.tsx +19 -8
- package/node_modules/projectstore/hooks/session-stop.mjs +43 -20
- package/node_modules/projectstore/package.json +1 -1
- package/node_modules/projectstore/scripts/band-shared.mjs +17 -2
- package/node_modules/projectstore/scripts/band.mjs +20 -2
- package/node_modules/projectstore/scripts/convergence.mjs +56 -1
- package/node_modules/projectstore/scripts/doctor.mjs +56 -10
- package/node_modules/projectstore/scripts/harness.mjs +68 -12
- package/node_modules/projectstore/scripts/install-harness.mjs +153 -14
- package/node_modules/projectstore/scripts/lib.mjs +271 -54
- package/node_modules/projectstore/scripts/setup.mjs +7 -5
- package/node_modules/projectstore/scripts/surfaces.mjs +36 -9
- package/node_modules/projectstore/scripts/touch-session.mjs +30 -15
- package/node_modules/projectstore/scripts/version-guard.mjs +48 -2
- package/package.json +2 -2
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
//
|
|
20
20
|
// Normative: the install spec — contract 0 (exclusive vs shared), 3–4 (the
|
|
21
21
|
// four states and the ladder, rungs 1′ and 1″ included), 6 (per-entry states
|
|
22
|
-
// for shared files: ours-current, ours-stale, ours-absent,
|
|
22
|
+
// for shared files: ours-current, ours-stale, ours-newer — 2026-10-14 —, ours-absent,
|
|
23
|
+
// unparseable),
|
|
23
24
|
// 12 (current, last written by), 14 (host-managed surfaces have no state to
|
|
24
25
|
// derive), 16 (a harness is in use when detected or when it has files of
|
|
25
26
|
// ours). Pure node, no external deps.
|
|
@@ -37,6 +38,8 @@ import {
|
|
|
37
38
|
desiredStatusLineCommand,
|
|
38
39
|
findAgentsBlock,
|
|
39
40
|
agentsBlockVersion,
|
|
41
|
+
newerBlockWords,
|
|
42
|
+
isNewerBlock,
|
|
40
43
|
agentsBlockTemplatePath,
|
|
41
44
|
renderAgentsBlock,
|
|
42
45
|
loadLayout,
|
|
@@ -185,6 +188,15 @@ export function analyseBlock(
|
|
|
185
188
|
a.current = withBlock.find((e) => e.file === preferred.file) || withBlock[0] || null;
|
|
186
189
|
a.duplicates = withBlock.filter((e) => e !== a.current);
|
|
187
190
|
if (!a.current) return { ...a, state: "ours-absent" };
|
|
191
|
+
// A marker above this copy's template is never rendered down (the install
|
|
192
|
+
// spec, contracts 4 and 6, as amended 2026-10-14). Placement still decides
|
|
193
|
+
// first (rungs B4–B6), and a block that moves, migrates or is deduplicated
|
|
194
|
+
// carries the newest block's own text; one that stays is `ours-newer`
|
|
195
|
+
// (B7), before the render is compared, so its roster is never re-rendered.
|
|
196
|
+
const newest = withBlock.reduce((n, e) => (e.block.v > n.block.v ? e : n), a.current);
|
|
197
|
+
a.newer = isNewerBlock(newest.block.v, a.version) ? newest : null;
|
|
198
|
+
const words = a.newer ? newerBlockWords(a.newer.block.v, a.version) : null;
|
|
199
|
+
const carried = a.newer ? ` with its own text (${words})` : "";
|
|
188
200
|
// A block this harness CANNOT READ moves, and its target is created if it has
|
|
189
201
|
// to be: that is the whole difference between a second harness arriving and a
|
|
190
202
|
// preference. A block it can read moves only when the preferred file already
|
|
@@ -197,17 +209,25 @@ export function analyseBlock(
|
|
|
197
209
|
return {
|
|
198
210
|
...a,
|
|
199
211
|
state: "ours-stale",
|
|
200
|
-
reason: `in ${a.current.file}, which ${reader || s.harness_display || "this harness"} does not read; install moves it to ${preferred.file}`,
|
|
212
|
+
reason: `in ${a.current.file}, which ${reader || s.harness_display || "this harness"} does not read; install moves it to ${preferred.file}${carried}`,
|
|
201
213
|
};
|
|
202
214
|
}
|
|
203
215
|
if (a.current.file !== preferred.file && preferred.present)
|
|
204
|
-
return {
|
|
216
|
+
return {
|
|
217
|
+
...a,
|
|
218
|
+
state: "ours-stale",
|
|
219
|
+
reason: `in ${a.current.file}; install migrates it to ${preferred.file}${carried}`,
|
|
220
|
+
};
|
|
205
221
|
if (a.duplicates.length)
|
|
206
222
|
return {
|
|
207
223
|
...a,
|
|
208
224
|
state: "ours-stale",
|
|
209
|
-
reason:
|
|
225
|
+
reason:
|
|
226
|
+
a.newer && a.newer !== a.current
|
|
227
|
+
? `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}, and ${a.newer.file}'s carries v${a.newer.block.v}, newer than this copy's v${a.version}: its text replaces the block in ${a.current.file}`
|
|
228
|
+
: `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}${carried}`,
|
|
210
229
|
};
|
|
230
|
+
if (a.newer) return { ...a, state: "ours-newer", reason: `${words}: left as it is` };
|
|
211
231
|
if (a.current.block.v === a.version && a.current.block.block === a.desired) return { ...a, state: "ours-current" };
|
|
212
232
|
return {
|
|
213
233
|
...a,
|
|
@@ -223,8 +243,15 @@ export function analyseBlock(
|
|
|
223
243
|
|
|
224
244
|
// `renderRoot` (contract 4′, two phases): the root the surface is planned
|
|
225
245
|
// AGAINST — the host's install path a registration produces — when it differs
|
|
226
|
-
// from the root the templates are read from. Defaults to `root`.
|
|
227
|
-
|
|
246
|
+
// from the root the templates are read from. Defaults to `root`. `harness` is
|
|
247
|
+
// the one the entry is FOR: its launcher lives under its own state directory,
|
|
248
|
+
// whichever harness launched this process (the install spec, contract 6, as
|
|
249
|
+
// amended 2026-10-14).
|
|
250
|
+
export function analyseJsonEntry(
|
|
251
|
+
projectDir,
|
|
252
|
+
s,
|
|
253
|
+
{ root = pluginRoot(), home = homedir(), renderRoot = root, harness = null } = {},
|
|
254
|
+
) {
|
|
228
255
|
const path = join(projectDir, s.file);
|
|
229
256
|
const pointer = s.marker?.pointer || "statusLine.command";
|
|
230
257
|
const entryKey = pointer.split(".")[0];
|
|
@@ -263,7 +290,7 @@ export function analyseJsonEntry(projectDir, s, { root = pluginRoot(), home = ho
|
|
|
263
290
|
a.ours =
|
|
264
291
|
statusLineIsOurWiring(a.curCmd, projectDir, home, root) ||
|
|
265
292
|
statusLineIsOurWiring(a.curCmd, projectDir, home, renderRoot);
|
|
266
|
-
a.desired = desiredStatusLineCommand(projectDir, renderRoot, home).command;
|
|
293
|
+
a.desired = desiredStatusLineCommand(projectDir, renderRoot, home, harness?.id).command;
|
|
267
294
|
if (!a.curEntry) return { ...a, state: "ours-absent" };
|
|
268
295
|
if (!a.ours) return { ...a, state: "theirs", reason: "a status line we did not write owns the slot" };
|
|
269
296
|
if (a.curCmd === a.desired) return { ...a, state: "ours-current" };
|
|
@@ -317,7 +344,7 @@ export function analyseStampedFile(
|
|
|
317
344
|
const tpl = readText(src);
|
|
318
345
|
if (!tpl.present || tpl.text === null)
|
|
319
346
|
return { ...a, state: "unparseable", refusal: `the source ${s.source} is missing from the plugin at ${root}` };
|
|
320
|
-
const rendered = renderStatusLineLauncher(tpl.text, renderRoot, projectDir);
|
|
347
|
+
const rendered = renderStatusLineLauncher(tpl.text, renderRoot, projectDir, process.env, harness);
|
|
321
348
|
if (rendered === null)
|
|
322
349
|
return { ...a, state: "unparseable", refusal: "the launcher template is not one this installer knows how to fill" };
|
|
323
350
|
const pkg = pluginVersionAt(root, harness) || "0.0.0";
|
|
@@ -791,7 +818,7 @@ export function surfaceStates(
|
|
|
791
818
|
const hasOurs = states.some(
|
|
792
819
|
(x) =>
|
|
793
820
|
(x.kind === "exclusive" && ["current", "stale"].includes(x.state)) ||
|
|
794
|
-
(x.kind === "shared" && ["ours-current", "ours-stale"].includes(x.state)),
|
|
821
|
+
(x.kind === "shared" && ["ours-current", "ours-stale", "ours-newer"].includes(x.state)),
|
|
795
822
|
);
|
|
796
823
|
if (detected.includes(m.id) || hasOurs) {
|
|
797
824
|
out.used.push(m.id);
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
// user's tool call.
|
|
34
34
|
|
|
35
35
|
import { existsSync } from "node:fs";
|
|
36
|
+
import { isAbsolute, resolve } from "node:path";
|
|
36
37
|
import {
|
|
37
38
|
readConfig,
|
|
38
39
|
readStdinJson,
|
|
@@ -43,7 +44,8 @@ import {
|
|
|
43
44
|
sessionFilePath,
|
|
44
45
|
projectRoot,
|
|
45
46
|
appendActivity,
|
|
46
|
-
|
|
47
|
+
vaultLocation,
|
|
48
|
+
vaultRootOf,
|
|
47
49
|
pointerPatchFor,
|
|
48
50
|
readSessionState,
|
|
49
51
|
writeSessionState,
|
|
@@ -91,7 +93,7 @@ function layoutOnce(cfg) {
|
|
|
91
93
|
return LAYOUT;
|
|
92
94
|
}
|
|
93
95
|
|
|
94
|
-
function anchorOffer(cfg, proj, sid,
|
|
96
|
+
function anchorOffer(cfg, proj, sid, rel, toolName, isSubagent, sessionsDir = null) {
|
|
95
97
|
// The two gates. The pointer patch below deliberately has neither: it answers
|
|
96
98
|
// "what am I looking at", for which a Read is a fine answer. A NAME is a
|
|
97
99
|
// claim about what the session is DOING, so a review session that greps
|
|
@@ -103,7 +105,6 @@ function anchorOffer(cfg, proj, sid, filePath, toolName, isSubagent, sessionsDir
|
|
|
103
105
|
// tally and logs nothing for one (generation spec, contract 18).
|
|
104
106
|
if (!speakingHarness()?.capabilities?.session_rename) return null;
|
|
105
107
|
|
|
106
|
-
const rel = filePath.slice(cfg.vault_path.length + 1);
|
|
107
108
|
let hit;
|
|
108
109
|
try {
|
|
109
110
|
hit = anchorKeyOf(rel, layoutOnce(cfg));
|
|
@@ -181,11 +182,11 @@ function anchorOffer(cfg, proj, sid, filePath, toolName, isSubagent, sessionsDir
|
|
|
181
182
|
// old status, and a close is the last touch a story gets (ADR-006 decision 1,
|
|
182
183
|
// amended 2026-10-12). Any other tool's (a Read) is made on PreToolUse.
|
|
183
184
|
// Returns the nudge's line, or null.
|
|
184
|
-
function patchPointer(cfg, proj, sid,
|
|
185
|
+
function patchPointer(cfg, proj, sid, placed, toolName) {
|
|
185
186
|
const cur = readSessionState(proj, sid) || {};
|
|
186
187
|
let patch = null;
|
|
187
|
-
for (const
|
|
188
|
-
const p = pointerPatchFor(cfg.vault_path,
|
|
188
|
+
for (const { path, loc } of placed) {
|
|
189
|
+
const p = pointerPatchFor(cfg.vault_path, path, { ...cur, ...patch }, undefined, { loc });
|
|
189
190
|
if (p) patch = { ...(patch || {}), ...p };
|
|
190
191
|
}
|
|
191
192
|
|
|
@@ -193,7 +194,12 @@ function patchPointer(cfg, proj, sid, vaultPaths, toolName) {
|
|
|
193
194
|
if (isWriteTool(toolName || "") && cfg.guard !== "off") {
|
|
194
195
|
const last = cur.nudged_at ? Date.parse(cur.nudged_at) : 0;
|
|
195
196
|
if (Date.now() - last > NUDGE_INTERVAL_MS) {
|
|
196
|
-
|
|
197
|
+
// A reconcile acts on the bound vault, so an edit in a worktree's copy
|
|
198
|
+
// is reconciled there once it is merged, not now.
|
|
199
|
+
const inCopy = placed.some(({ loc }) => loc.root !== vaultRootOf(cfg.vault_path));
|
|
200
|
+
nudge = inCopy
|
|
201
|
+
? `projectstore: vault file edited in a worktree of the vault — if this bypassed a ${commandForm("*")} command, run ${commandForm("reconcile")} in the bound vault after the merge so the board/indexes stay in sync.`
|
|
202
|
+
: `projectstore: vault file edited directly — if this bypassed a ${commandForm("*")} command, run ${commandForm("reconcile")} (or doctor) afterwards so the board/indexes stay in sync.`;
|
|
197
203
|
patch = { ...(patch || {}), nudged_at: new Date().toISOString() };
|
|
198
204
|
}
|
|
199
205
|
}
|
|
@@ -246,7 +252,7 @@ async function entryBranch(cfg, proj, sid, filePath, input) {
|
|
|
246
252
|
|
|
247
253
|
let verdict = readOpenStoryCache(proj, sid);
|
|
248
254
|
if (verdict === null) {
|
|
249
|
-
verdict = await resolveOpenStory(cfg.vault_path);
|
|
255
|
+
verdict = await resolveOpenStory(cfg.vault_path, { storyRoot: readSessionState(proj, sid)?.story_root });
|
|
250
256
|
writeOpenStoryCache(proj, sid, verdict);
|
|
251
257
|
}
|
|
252
258
|
// Reached either from a fresh sweep that hit its budget with reads still
|
|
@@ -312,7 +318,16 @@ async function main() {
|
|
|
312
318
|
const filePaths = toolPaths(input);
|
|
313
319
|
if (!filePaths.length) return;
|
|
314
320
|
|
|
315
|
-
|
|
321
|
+
// The vault side: any copy of the vault (a worktree included), with a
|
|
322
|
+
// relative tool path (Codex's apply_patch) taken against the payload's cwd.
|
|
323
|
+
// The entry rule below keeps the raw paths.
|
|
324
|
+
const cwd = input.cwd || proj;
|
|
325
|
+
const placed = [];
|
|
326
|
+
for (const p of filePaths) {
|
|
327
|
+
const abs = isAbsolute(p) ? p : resolve(cwd, p);
|
|
328
|
+
const loc = vaultLocation(abs, cfg.vault_path);
|
|
329
|
+
if (loc) placed.push({ path: abs, loc });
|
|
330
|
+
}
|
|
316
331
|
const writes = isWriteTool(input.tool_name || "");
|
|
317
332
|
|
|
318
333
|
if (event === "PostToolUse") {
|
|
@@ -321,9 +336,9 @@ async function main() {
|
|
|
321
336
|
// false" is a failed call; every other shape passes.
|
|
322
337
|
const response = input.tool_response;
|
|
323
338
|
const failed = response && typeof response === "object" && response.success === false;
|
|
324
|
-
if (writes && !failed &&
|
|
339
|
+
if (writes && !failed && placed.length) {
|
|
325
340
|
try {
|
|
326
|
-
const nudge = patchPointer(cfg, proj, sid,
|
|
341
|
+
const nudge = patchPointer(cfg, proj, sid, placed, input.tool_name);
|
|
327
342
|
if (nudge) messages.push(nudge);
|
|
328
343
|
} catch {}
|
|
329
344
|
}
|
|
@@ -345,12 +360,12 @@ async function main() {
|
|
|
345
360
|
|
|
346
361
|
const messages = [];
|
|
347
362
|
// A Read's pointer patch; a write's waits for PostToolUse (patchPointer).
|
|
348
|
-
if (!writes &&
|
|
363
|
+
if (!writes && placed.length) {
|
|
349
364
|
try {
|
|
350
|
-
patchPointer(cfg, proj, sid,
|
|
365
|
+
patchPointer(cfg, proj, sid, placed, input.tool_name);
|
|
351
366
|
} catch {}
|
|
352
367
|
}
|
|
353
|
-
for (const filePath of
|
|
368
|
+
for (const { path: filePath, loc } of placed) {
|
|
354
369
|
try {
|
|
355
370
|
appendActivity(cfg.vault_path, sid, filePath, input.tool_name);
|
|
356
371
|
} catch {}
|
|
@@ -360,7 +375,7 @@ async function main() {
|
|
|
360
375
|
cfg,
|
|
361
376
|
proj,
|
|
362
377
|
sid,
|
|
363
|
-
|
|
378
|
+
loc.rel,
|
|
364
379
|
input.tool_name,
|
|
365
380
|
Boolean(input.agent_id || input.agent_type),
|
|
366
381
|
process.env.PROJECTSTORE_SESSIONS_DIR || null,
|
|
@@ -16,7 +16,15 @@
|
|
|
16
16
|
// running it before a manual publish does not, because publish #1 is not
|
|
17
17
|
// tag-triggered and nothing else covers it.
|
|
18
18
|
//
|
|
19
|
-
//
|
|
19
|
+
// Then, with or without --tag, that the version has its release notes
|
|
20
|
+
// (PS-HARNESS: "A release tag shows on every surface…"): the file
|
|
21
|
+
// packaging/release-notes/v<version>.md, headed `# v<version> — <title>`, with
|
|
22
|
+
// a body. The release workflow makes the GitHub Release from that file, so a
|
|
23
|
+
// version without one is refused here — in the release PR, by test.yml and
|
|
24
|
+
// `npm run release:check` — rather than on the tag.
|
|
25
|
+
//
|
|
26
|
+
// Exit 0 when everything agrees, 1 on disagreement, a malformed manifest or
|
|
27
|
+
// missing notes.
|
|
20
28
|
// Output is JSON on stdout, always, so a workflow step can quote it.
|
|
21
29
|
|
|
22
30
|
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
@@ -198,6 +206,44 @@ export function checkVersions({ root = ROOT, tag = null } = {}) {
|
|
|
198
206
|
};
|
|
199
207
|
}
|
|
200
208
|
|
|
209
|
+
// The release notes (PS-HARNESS: "A release tag shows on every surface…").
|
|
210
|
+
// Under packaging/, which never ships: the notes are the GitHub Release's
|
|
211
|
+
// source, read from the repository by the release workflow.
|
|
212
|
+
export const RELEASE_NOTES_DIR = "packaging/release-notes";
|
|
213
|
+
export const releaseNotesPath = (version) => `${RELEASE_NOTES_DIR}/v${version}.md`;
|
|
214
|
+
|
|
215
|
+
// The heading is the release's title as GitHub shows it, `v0.30.4 — <title>`
|
|
216
|
+
// (an em dash, as every release title since v0.6.0 carries), and the body is
|
|
217
|
+
// what the release says. Both must be there: a heading alone is the "tagged
|
|
218
|
+
// without release notes" of 0.28.0 again.
|
|
219
|
+
export function checkReleaseNotes({ root = ROOT, version }) {
|
|
220
|
+
const rel = releaseNotesPath(version);
|
|
221
|
+
const refuse = (what) => ({ ok: false, error: `${rel}: ${what}`, notes: rel });
|
|
222
|
+
if (!existsSync(resolve(root, rel))) return refuse("missing — the release workflow makes the GitHub Release from it");
|
|
223
|
+
const [first = "", ...rest] = readFileSync(resolve(root, rel), "utf8").split("\n");
|
|
224
|
+
const heading = first.replace(/\r$/, "");
|
|
225
|
+
// The version the heading names, up to a space or the dash: `# v0.30.4—x`
|
|
226
|
+
// names 0.30.4 and fails the shape, not the version.
|
|
227
|
+
const named = /^# v([^\s\u2014]+)/.exec(heading)?.[1];
|
|
228
|
+
if (named !== undefined && named !== version) return refuse(`the heading names v${named}, not v${version}`);
|
|
229
|
+
const prefix = `# v${version} — `;
|
|
230
|
+
if (!heading.startsWith(prefix) || !heading.slice(prefix.length).trim())
|
|
231
|
+
return refuse(`the first line must be "# v${version} — <title>"`);
|
|
232
|
+
if (!rest.join("\n").trim()) return refuse("no body under the heading — the notes are the release's text");
|
|
233
|
+
return { ok: true, notes: rel };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// The whole guard: the versions, then the notes for the version they agree
|
|
237
|
+
// on. `checkVersions` stays as it was, so its callers (the packaging and
|
|
238
|
+
// shells contracts) keep their scratch roots without notes.
|
|
239
|
+
export function runGuard({ root = ROOT, tag = null } = {}) {
|
|
240
|
+
const v = checkVersions({ root, tag });
|
|
241
|
+
if (!v.ok) return v;
|
|
242
|
+
const n = checkReleaseNotes({ root, version: v.version });
|
|
243
|
+
if (!n.ok) return { ...n, version: v.version, checked: v.checked };
|
|
244
|
+
return { ...v, notes: n.notes };
|
|
245
|
+
}
|
|
246
|
+
|
|
201
247
|
// Everything at the repository root that is deliberately NOT in the tarball.
|
|
202
248
|
// This list is the whole point of the tree check below: `npm pack` can only
|
|
203
249
|
// report what `files[]` already allows, so comparing pack output against the
|
|
@@ -303,7 +349,7 @@ async function main(argv) {
|
|
|
303
349
|
return;
|
|
304
350
|
}
|
|
305
351
|
|
|
306
|
-
const result =
|
|
352
|
+
const result = runGuard({ tag });
|
|
307
353
|
process.stdout.write(JSON.stringify(result) + "\n");
|
|
308
354
|
if (!result.ok) process.exit(1);
|
|
309
355
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "projectstore-claude",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.5",
|
|
4
4
|
"description": "projectstore for Claude Code: the core pinned and bundled, the harness fixed. A release artifact; the way in is npx projectstore setup",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"projectstore",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"README.md"
|
|
38
38
|
],
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"projectstore": "=0.30.
|
|
40
|
+
"projectstore": "=0.30.5"
|
|
41
41
|
},
|
|
42
42
|
"bundleDependencies": [
|
|
43
43
|
"projectstore"
|