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.
@@ -24,12 +24,13 @@ import { join, dirname, basename, resolve, relative, isAbsolute } from "node:pat
24
24
  import { fileURLToPath } from "node:url";
25
25
  import { hostname, homedir } from "node:os";
26
26
  import { createHash, randomUUID } from "node:crypto";
27
- import { parseFrontmatter } from "./stages.mjs";
27
+ import { parseFrontmatter, stageOf } from "./stages.mjs";
28
28
  import { storyKeyOf } from "./band-shared.mjs";
29
29
  import {
30
30
  projectRoot as harnessProjectRoot,
31
31
  pluginRoot as harnessPluginRoot,
32
32
  agentHome as harnessAgentHome,
33
+ harnessHome,
33
34
  configPath as harnessConfigPath,
34
35
  projectConfigDir as harnessProjectConfigDir,
35
36
  detectHarnessId,
@@ -52,6 +53,7 @@ import {
52
53
  packageCommand,
53
54
  whichOnPath,
54
55
  hostSessionId,
56
+ stopCarrier,
55
57
  } from "./harness.mjs";
56
58
 
57
59
  // ─── Paths ─────────────────────────────────────────────────────────────
@@ -183,6 +185,7 @@ export {
183
185
  overlayId,
184
186
  speakingHarness,
185
187
  whichOnPath,
188
+ stopCarrier,
186
189
  };
187
190
 
188
191
  // A hook's payload carries the `cwd` of the session that fired it, and on a
@@ -545,12 +548,18 @@ export function pidAlive(pid) {
545
548
 
546
549
  // ─── Installed-plugin resolution ───────────────────────────────────────
547
550
 
548
- // The harness's config directory. Almost always ~/.claude, but the harness's
549
- // home variable relocates it — and a consumer that hardcodes the default
550
- // silently resolves nothing for those users instead of failing loudly. The
551
- // name is kept for its callers; the variable comes from the manifest.
551
+ // Claude Code's config directory. Almost always ~/.claude, but its home
552
+ // variable relocates it — and a consumer that hardcodes the default silently
553
+ // resolves nothing for those users instead of failing loudly. The variable
554
+ // comes from the source harness's manifest, never the active harness's: with
555
+ // another harness's home variable exported (CODEX_HOME), detection makes that
556
+ // harness active in a directory with no harness folder, and every Claude Code
557
+ // path used to follow it into the other home (the install spec, contract 6,
558
+ // as amended 2026-10-14). The host this process runs in is harnessAgentHome's
559
+ // question, asked by its own callers; it stands in here only if a manifest
560
+ // ever named no home at all.
552
561
  export function claudeHome(home = homedir()) {
553
- return harnessAgentHome(process.env, home);
562
+ return harnessHome(sourceHarness(), { env: process.env, home }) || harnessAgentHome(process.env, home);
554
563
  }
555
564
 
556
565
  // The release triple only: 0.28.0-rc.2 and 0.28.0 compare equal. Right for
@@ -633,9 +642,11 @@ export function pluginEnabled(key, home = homedir(), projectDir = null) {
633
642
  enabled = user.enabledPlugins[key] !== false;
634
643
  if (projectDir) {
635
644
  // The committed project file, then the checkout's local one over it — the
636
- // host's own precedence (measured 2026-09-05, --scope local).
645
+ // host's own precedence (measured 2026-09-05, --scope local). Claude Code's
646
+ // project directory, whichever harness this process runs as.
647
+ const dir = harnessProjectConfigDir(process.env, sourceHarness());
637
648
  for (const f of ["settings.json", "settings.local.json"]) {
638
- const proj = read(join(projectDir, harnessProjectConfigDir(), f));
649
+ const proj = read(join(projectDir, dir, f));
639
650
  if (proj && proj.enabledPlugins && Object.hasOwn(proj.enabledPlugins, key))
640
651
  enabled = proj.enabledPlugins[key] !== false;
641
652
  }
@@ -758,13 +769,17 @@ export function selectCopy(home = homedir(), projectDir, { harness = sourceHarne
758
769
  return { ...copy, bin, missing, channel: installChannel(copy.path, { home, harness }) };
759
770
  }
760
771
 
772
+ // How a git-marketplace copy is updated, in its own channel: the one phrase
773
+ // every advice about a kept or older copy quotes.
774
+ export const COPY_UPDATE_STEPS = "in Claude Code: /plugin marketplace update, then /plugin update, then restart";
775
+
761
776
  // The advice for a marketplace copy that cannot run what is named for it, in
762
777
  // that copy's own channel: the host's update, then a restart. `cannot` says
763
778
  // what it cannot do, `then` what follows the update. One text for doctor's
764
779
  // layout remedy and the keep's skip row and DONE line.
765
780
  export function copyUpdateAdvice(copy, { cannot, then }) {
766
781
  const v = copy?.version ? ` (${copy.version})` : "";
767
- return `This project's projectstore plugin${v} ${cannot}: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), ${then}`;
782
+ return `This project's projectstore plugin${v} ${cannot}: update it first (${COPY_UPDATE_STEPS}), ${then}`;
768
783
  }
769
784
 
770
785
  // The mark a channel switch leaves in this checkout's machine-local binding
@@ -1236,8 +1251,11 @@ export function statusLineIsOurWiring(cmd, projectDir, home = homedir(), root =
1236
1251
  // into state/<harness>/ made wrong; it is named instead, as plan() resolved
1237
1252
  // it — the same string the provenance line records, so an npx render and a
1238
1253
  // cache-install render stay byte-identical.
1239
- export function renderStatusLineLauncher(tpl, root, projectDir = projectRoot(), env = process.env) {
1240
- const names = runtimeEnvNames(env);
1254
+ // `harness` is the one the launcher is FOR (the plan's harness), so a render
1255
+ // from a process another harness launched still bakes in Claude Code's names;
1256
+ // the SessionStart refresh passes none and renders for its own host.
1257
+ export function renderStatusLineLauncher(tpl, root, projectDir = projectRoot(), env = process.env, harness) {
1258
+ const names = harness ? runtimeEnvNames(env, harness) : runtimeEnvNames(env);
1241
1259
  const subs = [
1242
1260
  ['"__PROJECTSTORE_ROOT__"', JSON.stringify(root)],
1243
1261
  ['"__PROJECTSTORE_HOME_ENV__"', JSON.stringify(names.home || "")],
@@ -1281,13 +1299,20 @@ export function writeStatusLineLauncher(projectDir, root) {
1281
1299
  // The command our entry should carry for this installation: the version-free
1282
1300
  // launcher for a marketplace-cache install, the plugin's own script for a dev
1283
1301
  // checkout (its path has no version to go stale). One resolver, shared by the
1284
- // SessionStart refresh and the installer, so the two cannot disagree.
1285
- export function desiredStatusLineCommand(projectDir, root = pluginRoot(), home = homedir()) {
1302
+ // SessionStart refresh and the installer, so the two cannot disagree. The
1303
+ // installer names the harness it plans (its launcher's state directory); the
1304
+ // refresh names none and means its own host.
1305
+ export function desiredStatusLineCommand(
1306
+ projectDir,
1307
+ root = pluginRoot(),
1308
+ home = homedir(),
1309
+ harnessId = activeHarnessId(),
1310
+ ) {
1286
1311
  const launcher = isPluginCacheRoot(root, home);
1287
1312
  return {
1288
1313
  launcher,
1289
1314
  command: launcher
1290
- ? `node "${statusLineLauncherPath(projectDir)}"`
1315
+ ? `node "${statusLineLauncherPath(projectDir, harnessId)}"`
1291
1316
  : `node "${join(root, "scripts", "statusline.mjs")}"`,
1292
1317
  };
1293
1318
  }
@@ -1416,6 +1441,19 @@ export function agentsBlockVersion(tmpl = null) {
1416
1441
  return m ? Number(m[1]) : null;
1417
1442
  }
1418
1443
 
1444
+ // A block whose marker is above this copy's template (the install spec,
1445
+ // contract 6, as amended 2026-10-14): the words every surface says it with.
1446
+ // Never "written by a newer projectstore" — a hand-edited marker reads the same.
1447
+ export function newerBlockWords(v, version) {
1448
+ return `marked v${v}, newer than this copy's v${version}`;
1449
+ }
1450
+
1451
+ // Whether a marker ranks above the template's. A template with no marker
1452
+ // (or a marker that is not a number) is never outranked.
1453
+ export function isNewerBlock(v, version) {
1454
+ return Number.isInteger(v) && Number.isInteger(version) && v > version;
1455
+ }
1456
+
1419
1457
  // The first block in `text`: its version, its span, whether it closes, and how
1420
1458
  // many open markers the file carries (more than one is a duplicate).
1421
1459
  export function findAgentsBlock(text) {
@@ -2545,10 +2583,16 @@ export async function gatherVaultFacts(cfg, opts = {}) {
2545
2583
  return;
2546
2584
  }
2547
2585
  const entries = await readActivityAsync(vault, sessionId, readFile);
2548
- const rel = entries
2549
- .filter((e) => e && typeof e.path === "string" && isInsideVault(e.path, vault))
2550
- .map((e) => e.path.slice(vault.length + 1))
2551
- .filter(Boolean);
2586
+ // Through the vault's worktrees too: a worktree's copy reads as its path
2587
+ // inside the vault, once, newest first.
2588
+ const rel = [
2589
+ ...new Set(
2590
+ entries
2591
+ .filter((e) => e && typeof e.path === "string")
2592
+ .map((e) => vaultLocation(e.path, vault)?.rel)
2593
+ .filter(Boolean),
2594
+ ),
2595
+ ];
2552
2596
  continuity.paths = rel;
2553
2597
  continuity.total = rel.length;
2554
2598
  continuity.artifact = resolveInFlightArtifact(entries, layout, vault);
@@ -2947,8 +2991,10 @@ export function resolveInFlightArtifact(activity, layout, vaultPath) {
2947
2991
  // the newest one — no sort, and no second definition of "newest".
2948
2992
  for (const e of activity) {
2949
2993
  if (!e || !e.path || !isWriteTool(e.tool)) continue;
2950
- if (!isInsideVault(e.path, root)) continue;
2951
- const rel = e.path.slice(root.length + 1);
2994
+ // A worktree's copy counts as the vault, by its path inside that copy.
2995
+ const loc = vaultLocation(e.path, root);
2996
+ if (!loc || !loc.rel) continue;
2997
+ const rel = loc.rel;
2952
2998
  if (folders.some((p) => rel === p || rel.startsWith(p + "/"))) return rel;
2953
2999
  }
2954
3000
  return null;
@@ -2960,6 +3006,132 @@ export function isInsideVault(filePath, vaultPath) {
2960
3006
  return norm === vaultPath || norm.startsWith(vaultPath + "/");
2961
3007
  }
2962
3008
 
3009
+ // ─── Where a path sits in the vault, worktrees included ────────────────
3010
+ //
3011
+ // (PS-PRESENCE: "A session working in a worktree of the vault gets its epic
3012
+ // and story on the band…", issue #87.) A vault edited only through git
3013
+ // worktrees — `<vault>/.worktrees/x/` or a checkout elsewhere — was invisible
3014
+ // to every check that matched a prefix on vault_path. vaultLocation answers
3015
+ // `{ root, rel }`: the copy of the vault that holds the file (the bound
3016
+ // checkout, or a worktree's copy) and the path inside it; null when the file
3017
+ // is not in the vault.
3018
+ //
3019
+ // The prefix still answers first and is never taken away: a path under
3020
+ // vault_path is in the vault as today, nested foreign repositories included.
3021
+ // The resolver only ADDS placements, by reading git's own files on disk — it
3022
+ // runs on every tool call, so it spawns nothing:
3023
+ // * a `.git` directory is its own common dir;
3024
+ // * a `.git` file says `gitdir: <path>` (relative to the file's directory for
3025
+ // submodules and worktree.useRelativePaths); the gitdir's `commondir`
3026
+ // (relative to the gitdir) names the common dir, and without one — a
3027
+ // --separate-git-dir checkout, a submodule — the gitdir is the common dir;
3028
+ // * both sides go through realpath: git writes /private/tmp where a vault may
3029
+ // be bound as /tmp or through a symlink.
3030
+ // A worktree belongs to the vault when its common dir is the vault's AND the
3031
+ // file lies under the worktree's copy of the vault's place in its repository
3032
+ // (`vaultSub`): a vault kept as a folder of the project's repository shares
3033
+ // the project's common dir, and a project worktree's src/ is not the vault.
3034
+ // Anything unreadable answers with the prefix alone.
3035
+
3036
+ const VAULT_REPO = new Map();
3037
+ export function resetVaultLocation() {
3038
+ VAULT_REPO.clear();
3039
+ }
3040
+
3041
+ function gitIdentity(entry) {
3042
+ try {
3043
+ if (statSync(entry).isDirectory()) {
3044
+ const real = realpathSync(entry);
3045
+ return { gitdir: real, common: real };
3046
+ }
3047
+ const m = readFileSync(entry, "utf8").match(/^gitdir:\s*(.+?)\s*$/m);
3048
+ if (!m) return null;
3049
+ const gitdir = resolve(dirname(entry), m[1]);
3050
+ let common = gitdir;
3051
+ try {
3052
+ common = resolve(gitdir, readFileSync(join(gitdir, "commondir"), "utf8").trim());
3053
+ } catch {}
3054
+ return { gitdir: realpathSync(gitdir), common: realpathSync(common) };
3055
+ } catch {
3056
+ return null;
3057
+ }
3058
+ }
3059
+
3060
+ // The nearest directory at or above `start` holding a `.git` entry; with
3061
+ // `below`, only directories strictly inside it are considered.
3062
+ function nearestGit(start, below = null) {
3063
+ let d = start;
3064
+ for (;;) {
3065
+ if (below !== null && !d.startsWith(below + "/")) return null;
3066
+ if (existsSync(join(d, ".git"))) return d;
3067
+ const up = dirname(d);
3068
+ if (up === d) return null;
3069
+ d = up;
3070
+ }
3071
+ }
3072
+
3073
+ function vaultRepo(vault) {
3074
+ if (VAULT_REPO.has(vault)) return VAULT_REPO.get(vault);
3075
+ let out = null;
3076
+ try {
3077
+ const real = realpathSync(vault);
3078
+ const top = nearestGit(real);
3079
+ const id = top ? gitIdentity(join(top, ".git")) : null;
3080
+ if (id) out = { ...id, sub: real === top ? "" : real.slice(top.length + 1) };
3081
+ } catch {}
3082
+ VAULT_REPO.set(vault, out);
3083
+ return out;
3084
+ }
3085
+
3086
+ // The bound vault's path as the resolver compares it: no trailing slash (a
3087
+ // hand-edited binding may carry one).
3088
+ export function vaultRootOf(vaultPath) {
3089
+ return vaultPath && vaultPath.length > 1 ? vaultPath.replace(/\/+$/, "") : vaultPath;
3090
+ }
3091
+
3092
+ export function vaultLocation(filePath, vaultPath, { cwd } = {}) {
3093
+ if (!filePath || !vaultPath) return null;
3094
+ const vault = vaultRootOf(vaultPath);
3095
+ // resolve() also folds `//` and `..`, so a root is always a clean path.
3096
+ const norm = resolve(cwd || process.cwd(), filePath);
3097
+ const under = (p, root) => p === root || p.startsWith(root + "/");
3098
+ const relOf = (p, root) => (p === root ? "" : p.slice(root.length + 1));
3099
+ const copyOf = (top, repo) => (repo.sub ? join(top, repo.sub) : top);
3100
+
3101
+ if (under(norm, vault)) {
3102
+ // A worktree kept inside the vault folder answers for its own copy.
3103
+ const top = nearestGit(dirname(norm), vault);
3104
+ if (top) {
3105
+ const repo = vaultRepo(vault);
3106
+ const id = repo ? gitIdentity(join(top, ".git")) : null;
3107
+ if (id && id.common === repo.common && id.gitdir !== repo.gitdir) {
3108
+ const root = copyOf(top, repo);
3109
+ if (under(norm, root)) return { root, rel: relOf(norm, root) };
3110
+ }
3111
+ }
3112
+ return { root: vault, rel: relOf(norm, vault) };
3113
+ }
3114
+
3115
+ // Outside the prefix: a new file's walk starts at its nearest existing parent.
3116
+ let start = dirname(norm);
3117
+ while (!existsSync(start)) {
3118
+ const up = dirname(start);
3119
+ if (up === start) return null;
3120
+ start = up;
3121
+ }
3122
+ const top = nearestGit(start);
3123
+ if (!top) return null;
3124
+ const repo = vaultRepo(vault);
3125
+ if (!repo) return null;
3126
+ const id = gitIdentity(join(top, ".git"));
3127
+ if (!id || id.common !== repo.common) return null;
3128
+ const root = copyOf(top, repo);
3129
+ if (!under(norm, root)) return null;
3130
+ // The bound checkout spelled another way (/private/var for /var, a symlink).
3131
+ if (id.gitdir === repo.gitdir) return { root: vault, rel: relOf(norm, root) };
3132
+ return { root, rel: relOf(norm, root) };
3133
+ }
3134
+
2963
3135
  // ─── Per-session project-side state (statusline pointer — ADR-006) ─────
2964
3136
  //
2965
3137
  // <project>/.claude/.projectstore/state/<session_id>.json holds this
@@ -3058,13 +3230,29 @@ export function writeSessionState(projectDir, sessionId, patch) {
3058
3230
  // written as null (every reader treats it as absent): a touch of another
3059
3231
  // epic clears the previous epic's story, and an unreadable story names no
3060
3232
  // stale title. Null when the file is not under an epic.
3061
- export function pointerPatchFor(vaultPath, filePath, cur, read = (p) => readFileSync(p, "utf8")) {
3062
- const m = filePath.slice(vaultPath.length + 1).match(/^epics\/([^/]+)\/(.+)$/);
3233
+ //
3234
+ // The file may sit in a worktree of the vault (vaultLocation): the epic is
3235
+ // matched on its path inside that copy, epic.md is read from the same copy,
3236
+ // and `story_root` records the copy — null for the bound checkout — so the
3237
+ // band and SessionStart's refresh read the story where it lives. It is written
3238
+ // with the story fields: on a story touch, or when the epic changes. A touch
3239
+ // of the same epic's other files (reading the bound checkout's epic.md for
3240
+ // its goal) leaves it, or the band would look for the story where it is not.
3241
+ export function pointerPatchFor(
3242
+ vaultPath,
3243
+ filePath,
3244
+ cur,
3245
+ read = (p) => readFileSync(p, "utf8"),
3246
+ { loc = vaultLocation(filePath, vaultPath) } = {},
3247
+ ) {
3248
+ if (!loc) return null;
3249
+ const m = loc.rel.match(/^epics\/([^/]+)\/(.+)$/);
3063
3250
  if (!m) return null;
3064
3251
  const [, epicId, inEpic] = m;
3252
+ const storyRoot = loc.root === vaultRootOf(vaultPath) ? null : loc.root;
3065
3253
  const patch = { active_epic: epicId };
3066
3254
  try {
3067
- const { data } = parseFrontmatter(read(join(vaultPath, "epics", epicId, "epic.md")));
3255
+ const { data } = parseFrontmatter(read(join(loc.root, "epics", epicId, "epic.md")));
3068
3256
  if (data.title) patch.epic_title = String(data.title);
3069
3257
  } catch {}
3070
3258
  const key = storyKeyOf(inEpic);
@@ -3076,8 +3264,9 @@ export function pointerPatchFor(vaultPath, filePath, cur, read = (p) => readFile
3076
3264
  patch.active_story = key;
3077
3265
  patch.story_title = data && data.title ? String(data.title) : null;
3078
3266
  patch.story_status = data && data.status ? String(data.status) : null;
3267
+ patch.story_root = storyRoot;
3079
3268
  } else if (!cur || cur.active_epic !== epicId) {
3080
- Object.assign(patch, { active_story: null, story_title: null, story_status: null });
3269
+ Object.assign(patch, { active_story: null, story_title: null, story_status: null, story_root: storyRoot });
3081
3270
  }
3082
3271
  return patch;
3083
3272
  }
@@ -3104,12 +3293,14 @@ export function storyFileFor(vaultPath, epicId, key) {
3104
3293
  export function refreshSessionPointer(projectDir, cfg, path, { env = process.env } = {}) {
3105
3294
  const vault = cfg.vault_path;
3106
3295
  const abs = isAbsolute(path) ? resolve(path) : join(vault, path);
3107
- const rel = abs.startsWith(`${vault}/`) ? abs.slice(vault.length + 1) : null;
3296
+ // A worktree of the vault is the vault too: a close made there refreshes.
3297
+ const loc = vaultLocation(abs, vault);
3298
+ const rel = loc && loc.rel ? loc.rel : null;
3108
3299
  if (!rel) return { refusal: { code: "NOT_IN_VAULT", message: `${path} is not inside the bound vault (${vault})` } };
3109
3300
  if (!existsSync(abs)) return { refusal: { code: "NOT_FOUND", message: `${rel} does not exist in the vault` } };
3110
3301
  const sessionId = hostSessionId(env);
3111
3302
  const cur = sessionId ? readSessionState(projectDir, sessionId) : null;
3112
- const patch = pointerPatchFor(vault, abs, cur);
3303
+ const patch = pointerPatchFor(vault, abs, cur, undefined, { loc });
3113
3304
  if (!patch || !patch.active_story)
3114
3305
  return { refusal: { code: "NOT_A_STORY", message: `${rel} is not a story file under an epic` } };
3115
3306
  if (!sessionId) return { session: null, refreshed: false, reason: "no-session", path: rel };
@@ -3123,6 +3314,7 @@ export function refreshSessionPointer(projectDir, cfg, path, { env = process.env
3123
3314
  active_story: next.active_story ?? null,
3124
3315
  story_title: next.story_title ?? null,
3125
3316
  story_status: next.story_status ?? null,
3317
+ story_root: next.story_root ?? null,
3126
3318
  },
3127
3319
  };
3128
3320
  }
@@ -3138,35 +3330,51 @@ export function refreshSessionPointer(projectDir, cfg, path, { env = process.env
3138
3330
  export async function refreshStoryPointer(projectDir, vaultPath, sessionId, opts = {}) {
3139
3331
  const cur = readSessionState(projectDir, sessionId);
3140
3332
  if (!cur || !cur.active_epic || !cur.active_story) return "unchanged";
3141
- const file = storyFileFor(vaultPath, cur.active_epic, cur.active_story);
3142
- if (!file && !existsSync(join(vaultPath, "epics", cur.active_epic, "epic.md"))) return "unknown";
3143
- let title = null;
3144
- let status = null;
3145
- if (file) {
3333
+ // The copy the story was last touched in (a worktree of the vault), while it
3334
+ // exists; a removed worktree falls back to the bound checkout.
3335
+ const root =
3336
+ typeof cur.story_root === "string" && cur.story_root && cur.story_root !== vaultRootOf(vaultPath)
3337
+ ? cur.story_root
3338
+ : null;
3339
+ const rootLive = root ? existsSync(root) : false;
3340
+ const mainFile = storyFileFor(vaultPath, cur.active_epic, cur.active_story);
3341
+ const rootFile = rootLive ? storyFileFor(root, cur.active_epic, cur.active_story) : null;
3342
+ let patch;
3343
+ if (!mainFile && !rootFile) {
3344
+ const epicIn = (r) => existsSync(join(r, "epics", cur.active_epic, "epic.md"));
3345
+ if (!epicIn(vaultPath) && !(rootLive && epicIn(root))) return "unknown";
3346
+ patch = { active_story: null, story_title: null, story_status: null, story_root: rootLive ? root : null };
3347
+ } else {
3146
3348
  const readFile = opts.readFile || ((p) => readFileAsync(p, "utf8"));
3147
3349
  let timer = null;
3148
3350
  const deadline = new Promise((resolve) => {
3149
3351
  timer = setTimeout(() => resolve(null), opts.budgetMs ?? 200);
3150
3352
  });
3151
- let text;
3353
+ const readOne = (f) =>
3354
+ f
3355
+ ? Promise.resolve()
3356
+ .then(() => readFile(f))
3357
+ .then(String, () => null)
3358
+ : Promise.resolve("");
3359
+ let texts;
3152
3360
  try {
3153
- text = await Promise.race([
3154
- Promise.resolve()
3155
- .then(() => readFile(file))
3156
- .then(String, () => null),
3157
- deadline,
3158
- ]);
3361
+ texts = await Promise.race([Promise.all([readOne(mainFile), readOne(rootFile)]), deadline]);
3159
3362
  } finally {
3160
3363
  if (timer) clearTimeout(timer);
3161
3364
  }
3162
- if (text === null) return "unknown";
3163
- const { data } = parseFrontmatter(text);
3164
- title = data.title ? String(data.title) : null;
3165
- status = data.status ? String(data.status) : null;
3166
- }
3167
- const patch = file
3168
- ? { story_title: title, story_status: status }
3169
- : { active_story: null, story_title: null, story_status: null };
3365
+ if (texts === null || texts.some((t) => t === null)) return "unknown";
3366
+ const fields = (text) => {
3367
+ const { data } = parseFrontmatter(text);
3368
+ return { title: data.title ? String(data.title) : null, status: data.status ? String(data.status) : null };
3369
+ };
3370
+ const main = mainFile ? fields(texts[0]) : null;
3371
+ const copy = rootFile ? fields(texts[1]) : null;
3372
+ // A story closed on the bound checkout wins over a stale worktree copy
3373
+ // (the merge brought `done`); otherwise the copy being worked wins.
3374
+ const useMain = main && (!copy || stageOf(main.status) === "done");
3375
+ const pick = useMain ? main : copy;
3376
+ patch = { story_title: pick.title, story_status: pick.status, story_root: useMain ? null : root };
3377
+ }
3170
3378
  const same = Object.entries(patch).every(([k, v]) => (cur[k] ?? null) === v);
3171
3379
  if (same) return "unchanged";
3172
3380
  writeSessionState(projectDir, sessionId, patch);
@@ -3254,7 +3462,6 @@ export const ENTRY_IGNORE = [
3254
3462
 
3255
3463
  export function isSourcePath(absPath, projectDir, vaultPath) {
3256
3464
  if (!absPath || !projectDir) return false;
3257
- if (vaultPath && isInsideVault(absPath, vaultPath)) return false;
3258
3465
  const root = projectDir.endsWith("/") ? projectDir.slice(0, -1) : projectDir;
3259
3466
  if (!absPath.startsWith(root + "/")) return false;
3260
3467
  // Matched project-relative, never absolute: SOURCE_IGNORE is
@@ -3262,7 +3469,10 @@ export function isSourcePath(absPath, projectDir, vaultPath) {
3262
3469
  // project that merely lives under a directory called `build`.
3263
3470
  const rel = absPath.slice(root.length + 1);
3264
3471
  if (!rel) return false;
3265
- return !ENTRY_IGNORE.some((re) => re.test(rel));
3472
+ if (ENTRY_IGNORE.some((re) => re.test(rel))) return false;
3473
+ // Last, because it walks the disk: a file in the vault — a worktree's copy
3474
+ // of it included (contract 1, as amended 2026-10-15) — is never source.
3475
+ return !(vaultPath && vaultLocation(absPath, vaultPath));
3266
3476
  }
3267
3477
 
3268
3478
  export function scoreDir(projectDir, sessionId) {
@@ -3347,7 +3557,14 @@ export function listVaultStoryFiles(vaultPath) {
3347
3557
  export async function resolveOpenStory(vaultPath, opts = {}) {
3348
3558
  const budgetMs = opts.budgetMs ?? 200;
3349
3559
  const readFile = opts.readFile || ((p) => readFileAsync(p, "utf8"));
3350
- const files = listVaultStoryFiles(vaultPath);
3560
+ // The session's own worktree copy first (its pointer's story_root), then the
3561
+ // bound checkout: a story open where this session works it counts as open.
3562
+ const storyRoot = opts.storyRoot;
3563
+ const own =
3564
+ typeof storyRoot === "string" && storyRoot && storyRoot !== vaultRootOf(vaultPath) && existsSync(storyRoot)
3565
+ ? listVaultStoryFiles(storyRoot)
3566
+ : [];
3567
+ const files = [...own, ...listVaultStoryFiles(vaultPath)];
3351
3568
  if (!files.length) return false;
3352
3569
 
3353
3570
  let timer = null;
@@ -3545,12 +3762,12 @@ export const ENTRY_THRESHOLD = 3;
3545
3762
  // Normative text. Three properties it must keep, whatever the wording: it shows
3546
3763
  // the evidence (the count), it names the action, and it grants the exit — so a
3547
3764
  // false positive costs a glance rather than an argument.
3548
- export function entryReminderText(n) {
3765
+ export function entryReminderText(n, { env = process.env } = {}) {
3549
3766
  return [
3550
3767
  `**projectstore**: this session has written to ${n} source files and no story`,
3551
3768
  "is in progress. If this is feature-sized work, open it in the vault before",
3552
- `going further — \`${commandForm("story", { args: '<EPIC> "<title>"' })}\`, or`,
3553
- `\`${commandForm("epic")}\` if it needs a new one. If it is a one-off fix, carry`,
3769
+ `going further — \`${commandForm("story", { args: '<EPIC> "<title>"', env })}\`, or`,
3770
+ `\`${commandForm("epic", { env })}\` if it needs a new one. If it is a one-off fix, carry`,
3554
3771
  "on — this fires once.",
3555
3772
  ].join("\n");
3556
3773
  }
@@ -3871,7 +4088,7 @@ export function writeAnchorOffer(projectDir, sessionId, rec) {
3871
4088
  // within moments of each other both pass this check and both offer the same
3872
4089
  // name — the race is narrowed, not closed, and the ADR says so.
3873
4090
  export function liveSessionNames(selfSessionId, dir = null) {
3874
- const root = dir || join(claudeHome(), "sessions");
4091
+ const root = dir || join(harnessAgentHome(process.env), "sessions");
3875
4092
  const out = [];
3876
4093
  let files;
3877
4094
  try {
@@ -3895,7 +4112,7 @@ export function liveSessionNames(selfSessionId, dir = null) {
3895
4112
 
3896
4113
  // What this session is currently called, if the harness has recorded a name.
3897
4114
  export function ownSessionName(selfSessionId, dir = null) {
3898
- const root = dir || join(claudeHome(), "sessions");
4115
+ const root = dir || join(harnessAgentHome(process.env), "sessions");
3899
4116
  let files;
3900
4117
  try {
3901
4118
  files = readdirSync(root);
@@ -64,7 +64,7 @@ import {
64
64
  languageNames,
65
65
  } from "./binding.mjs";
66
66
  import { analyseRegistration, analyseJsonEntry } from "./surfaces.mjs";
67
- import { runningCopies, coreCommand, channelChoices, pinFor } from "./convergence.mjs";
67
+ import { runningCopies, coreCommand, channelChoices, pinFor, terminalRun } from "./convergence.mjs";
68
68
  import { runVerb, isInteractive, gateText, channelText, publicItem, planOptions } from "./install-harness.mjs";
69
69
  import { resolveBinding } from "./worktree.mjs";
70
70
  import * as term from "./term.mjs";
@@ -328,7 +328,9 @@ function factsOf(project, { env, home, root, version, interactive }) {
328
328
  sessionCopy = selectCopy(home, project, { harness: channelHarness });
329
329
  }
330
330
  const slotHarness = manifests.find((m) => statuslineOf(m)) || null;
331
- const slot = slotHarness ? analyseJsonEntry(project, statuslineOf(slotHarness), { root, home }).state : null;
331
+ const slot = slotHarness
332
+ ? analyseJsonEntry(project, statuslineOf(slotHarness), { root, home, harness: slotHarness }).state
333
+ : null;
332
334
  // A worktree's main checkout (worktree.mjs): only where it can be offered —
333
335
  // at a terminal, unbound, in a linked worktree. Read for the project
334
336
  // answered, never from the git top, which --project leaves unread.
@@ -692,12 +694,12 @@ function doneLines(r, { project, runEnv, home, version, channel, vaultPlan, unde
692
694
  // Rule 13: `--channel npm` when that was the answer, and only for the
693
695
  // harness whose channel it is — setup refuses --channel in a run without
694
696
  // that harness (exit 2). A keep is not repeated: once recorded, it is the
695
- // default already (rule 6).
696
- const again = channel === "npm" && hostRegistrationOf(m) ? "--channel npm" : "";
697
+ // default already (rule 6). The deferred row's reason names the same
698
+ // command, from the same builder.
697
699
  if (left.length)
698
700
  blocks.push({
699
701
  lead: `Left for a terminal outside this ${m.display_name} session: ${left.join(", ")}. From a terminal in this project, run:`,
700
- commands: [coreCommand("setup", { version, args: [`--harness ${id}`, again].filter(Boolean).join(" ") })],
702
+ commands: [terminalRun({ verb: "setup", harness: m, version, channel })],
701
703
  after: `then restart ${m.display_name}.`,
702
704
  });
703
705
  }