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.
@@ -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, unparseable),
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 { ...a, state: "ours-stale", reason: `in ${a.current.file}; install migrates it to ${preferred.file}` };
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: `also in ${a.duplicates.map((e) => e.file).join(", ")}; install keeps the one in ${a.current.file}`,
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
- export function analyseJsonEntry(projectDir, s, { root = pluginRoot(), home = homedir(), renderRoot = root } = {}) {
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
- isInsideVault,
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, filePath, toolName, isSubagent, sessionsDir = null) {
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, vaultPaths, toolName) {
185
+ function patchPointer(cfg, proj, sid, placed, toolName) {
185
186
  const cur = readSessionState(proj, sid) || {};
186
187
  let patch = null;
187
- for (const filePath of vaultPaths) {
188
- const p = pointerPatchFor(cfg.vault_path, filePath, { ...cur, ...patch });
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
- nudge = `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
+ // 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
- const vaultPaths = filePaths.filter((p) => isInsideVault(p, cfg.vault_path));
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 && vaultPaths.length) {
339
+ if (writes && !failed && placed.length) {
325
340
  try {
326
- const nudge = patchPointer(cfg, proj, sid, vaultPaths, input.tool_name);
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 && vaultPaths.length) {
363
+ if (!writes && placed.length) {
349
364
  try {
350
- patchPointer(cfg, proj, sid, vaultPaths, input.tool_name);
365
+ patchPointer(cfg, proj, sid, placed, input.tool_name);
351
366
  } catch {}
352
367
  }
353
- for (const filePath of vaultPaths) {
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
- filePath,
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
- // Exit 0 when everything agrees, 1 on disagreement or a malformed manifest.
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 = checkVersions({ tag });
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",
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.3"
40
+ "projectstore": "=0.30.5"
41
41
  },
42
42
  "bundleDependencies": [
43
43
  "projectstore"