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
|
@@ -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
|
-
//
|
|
549
|
-
//
|
|
550
|
-
//
|
|
551
|
-
//
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
1240
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
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
|
-
|
|
2951
|
-
const
|
|
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
|
-
|
|
3062
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
3163
|
-
const
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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: [
|
|
702
|
+
commands: [terminalRun({ verb: "setup", harness: m, version, channel })],
|
|
701
703
|
after: `then restart ${m.display_name}.`,
|
|
702
704
|
});
|
|
703
705
|
}
|