projectstore-claude 0.29.2 → 0.29.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/projectstore-claude.mjs +1 -1
- package/node_modules/projectstore/.claude-plugin/marketplace.json +1 -1
- package/node_modules/projectstore/.claude-plugin/plugin.json +2 -1
- package/node_modules/projectstore/README.md +44 -7
- package/node_modules/projectstore/commands/bar.md +30 -0
- package/node_modules/projectstore/commands/bind.md +16 -20
- package/node_modules/projectstore/commands/doctor.md +12 -7
- package/node_modules/projectstore/commands/scaffold.md +24 -16
- package/node_modules/projectstore/commands/status.md +1 -1
- package/node_modules/projectstore/commands/statusline.md +2 -2
- package/node_modules/projectstore/commands/story.md +15 -2
- package/node_modules/projectstore/docs/alongside.md +167 -0
- package/node_modules/projectstore/docs/extending.md +17 -3
- package/node_modules/projectstore/docs/getting-started.md +18 -9
- package/node_modules/projectstore/docs/harnesses.md +1 -1
- package/node_modules/projectstore/docs/how-it-works.md +12 -3
- package/node_modules/projectstore/harnesses/claude-code.json +23 -8
- package/node_modules/projectstore/harnesses/codex.json +16 -6
- package/node_modules/projectstore/hooks/band-state.d.ts +44 -0
- package/node_modules/projectstore/hooks/band.tsx +622 -0
- package/node_modules/projectstore/hooks/hooks.json +1 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +7 -10
- package/node_modules/projectstore/hooks/session-rules.mjs +14 -8
- package/node_modules/projectstore/hooks/session-start.mjs +56 -20
- package/node_modules/projectstore/hooks/session-stop.mjs +8 -6
- package/node_modules/projectstore/package.json +12 -2
- package/node_modules/projectstore/scaffold/headings.json +8 -0
- package/node_modules/projectstore/scripts/band-shared.mjs +446 -0
- package/node_modules/projectstore/scripts/band.mjs +207 -0
- package/node_modules/projectstore/scripts/binding.mjs +733 -62
- package/node_modules/projectstore/scripts/build-adapters.mjs +98 -35
- package/node_modules/projectstore/scripts/cli.mjs +1463 -267
- package/node_modules/projectstore/scripts/codemap.mjs +22 -7
- package/node_modules/projectstore/scripts/convergence.mjs +638 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +14 -8
- package/node_modules/projectstore/scripts/doctor-report.mjs +253 -0
- package/node_modules/projectstore/scripts/doctor.mjs +1384 -420
- package/node_modules/projectstore/scripts/draft.mjs +5 -2
- package/node_modules/projectstore/scripts/fetch-shell.mjs +369 -0
- package/node_modules/projectstore/scripts/graph.mjs +19 -13
- package/node_modules/projectstore/scripts/harness.mjs +283 -37
- package/node_modules/projectstore/scripts/install-harness.mjs +4701 -575
- package/node_modules/projectstore/scripts/kanban.mjs +6 -21
- package/node_modules/projectstore/scripts/lib.mjs +933 -199
- package/node_modules/projectstore/scripts/mcp.mjs +386 -105
- package/node_modules/projectstore/scripts/portable-registration.mjs +276 -56
- package/node_modules/projectstore/scripts/provenance.mjs +26 -10
- package/node_modules/projectstore/scripts/query.mjs +441 -72
- package/node_modules/projectstore/scripts/reconcile.mjs +55 -18
- package/node_modules/projectstore/scripts/run-host.mjs +139 -0
- package/node_modules/projectstore/scripts/scaffold.mjs +244 -0
- package/node_modules/projectstore/scripts/setup.mjs +987 -0
- package/node_modules/projectstore/scripts/stages.mjs +84 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +36 -11
- package/node_modules/projectstore/scripts/statusline.mjs +55 -14
- package/node_modules/projectstore/scripts/story-section.mjs +39 -17
- package/node_modules/projectstore/scripts/surfaces.mjs +485 -99
- package/node_modules/projectstore/scripts/term.mjs +1027 -52
- package/node_modules/projectstore/scripts/tokens.mjs +106 -28
- package/node_modules/projectstore/scripts/touch-session.mjs +109 -70
- package/node_modules/projectstore/scripts/version-guard.mjs +79 -27
- package/node_modules/projectstore/scripts/worktree.mjs +16 -5
- package/node_modules/projectstore/templates/de/strings.json +63 -1
- package/node_modules/projectstore/templates/de/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/en/strings.json +63 -1
- package/node_modules/projectstore/templates/en/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/es/strings.json +63 -1
- package/node_modules/projectstore/templates/es/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/fr/strings.json +63 -1
- package/node_modules/projectstore/templates/fr/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/ru/strings.json +63 -1
- package/node_modules/projectstore/templates/ru/vault-readme.md.tmpl +13 -0
- package/node_modules/projectstore/templates/zh/strings.json +63 -1
- package/node_modules/projectstore/templates/zh/vault-readme.md.tmpl +13 -0
- package/package.json +2 -2
|
@@ -2,12 +2,30 @@
|
|
|
2
2
|
// Pure node, no external deps. Keep this single-file & dependency-free
|
|
3
3
|
// so plugin install does not require npm install.
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
readFileSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
writeSync,
|
|
9
|
+
appendFileSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
statSync,
|
|
13
|
+
lstatSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
utimesSync,
|
|
16
|
+
unlinkSync,
|
|
17
|
+
renameSync,
|
|
18
|
+
rmSync,
|
|
19
|
+
realpathSync,
|
|
20
|
+
copyFileSync,
|
|
21
|
+
} from "node:fs";
|
|
6
22
|
import { readFile as readFileAsync } from "node:fs/promises";
|
|
7
23
|
import { join, dirname, basename, resolve, relative, isAbsolute } from "node:path";
|
|
8
24
|
import { fileURLToPath } from "node:url";
|
|
9
25
|
import { hostname, homedir } from "node:os";
|
|
10
26
|
import { createHash, randomUUID } from "node:crypto";
|
|
27
|
+
import { parseFrontmatter } from "./stages.mjs";
|
|
28
|
+
import { storyKeyOf } from "./band-shared.mjs";
|
|
11
29
|
import {
|
|
12
30
|
projectRoot as harnessProjectRoot,
|
|
13
31
|
pluginRoot as harnessPluginRoot,
|
|
@@ -20,6 +38,7 @@ import {
|
|
|
20
38
|
overlayId,
|
|
21
39
|
hostSettingsPath,
|
|
22
40
|
layoutPaths,
|
|
41
|
+
cachePaths,
|
|
23
42
|
pickExisting,
|
|
24
43
|
LAYOUT,
|
|
25
44
|
RUNTIME_GITIGNORE_HEADER,
|
|
@@ -31,6 +50,8 @@ import {
|
|
|
31
50
|
invocation,
|
|
32
51
|
speakingHarness,
|
|
33
52
|
packageCommand,
|
|
53
|
+
whichOnPath,
|
|
54
|
+
hostSessionId,
|
|
34
55
|
} from "./harness.mjs";
|
|
35
56
|
|
|
36
57
|
// ─── Paths ─────────────────────────────────────────────────────────────
|
|
@@ -73,8 +94,11 @@ export function updateHint({ env = process.env } = {}) {
|
|
|
73
94
|
const m = speakingHarness(env) || {};
|
|
74
95
|
// A harness that declares no hint gets none — never another harness's advice.
|
|
75
96
|
const hint = m.update_hint || { line: "", welcome: [] };
|
|
76
|
-
// `{upgrade}` is the
|
|
77
|
-
|
|
97
|
+
// `{upgrade}` is the core's upgrade for this harness, pinned to latest so it
|
|
98
|
+
// runs as printed, with no --project: the hint says where to run it (the
|
|
99
|
+
// story "User-facing text says where to run…", rule 2). Any hint that holds
|
|
100
|
+
// the placeholder gets it; one that holds none prints as written.
|
|
101
|
+
const upgrade = m.id ? packageCommand(m, "upgrade", { version: "latest" }) : "";
|
|
78
102
|
const fill = (t) => String(t).split("{upgrade}").join(upgrade);
|
|
79
103
|
return { line: fill(hint.line || ""), welcome: (hint.welcome || []).map(fill) };
|
|
80
104
|
}
|
|
@@ -96,17 +120,65 @@ export function bindInherits({ env = process.env } = {}) {
|
|
|
96
120
|
export function inheritForm(vaultPath, { layout = null, language = null, env = process.env } = {}) {
|
|
97
121
|
if (speakingHarness(env)?.capabilities?.bind_inherit) return commandForm("bind", { args: "--inherit", env });
|
|
98
122
|
const plain = (v) => typeof v === "string" && /^[a-z0-9-]+$/.test(v);
|
|
99
|
-
const args = [
|
|
123
|
+
const args = [
|
|
124
|
+
JSON.stringify(String(vaultPath)),
|
|
125
|
+
plain(layout) ? `--layout ${layout}` : "",
|
|
126
|
+
plain(language) ? `--language ${language}` : "",
|
|
127
|
+
]
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.join(" ");
|
|
100
130
|
return commandForm("bind", { args, env });
|
|
101
131
|
}
|
|
102
132
|
export function speakingDisplayName({ env = process.env } = {}) {
|
|
103
133
|
return speakingHarness(env)?.display_name || "another";
|
|
104
134
|
}
|
|
105
135
|
|
|
136
|
+
// ─── doctor's last line (presentation spec, contract 11) ───────────────
|
|
137
|
+
//
|
|
138
|
+
// The Summary line ends every text report of doctor's, verbatim from its
|
|
139
|
+
// 0.29.2 report: the write ceremony captures it as the pre-state and compares
|
|
140
|
+
// it after (commands/story.md step 5a, agents/clerk.md), so its bytes are a
|
|
141
|
+
// contract. It lives here, beside commandForm, because every printer of the
|
|
142
|
+
// report needs it — doctor-report.mjs, and the fallback of each caller when
|
|
143
|
+
// that renderer cannot be loaded or throws — and this is the one module all
|
|
144
|
+
// of them already import. `env` decides the command forms.
|
|
145
|
+
export function doctorSummaryLine(findings, { env = process.env } = {}) {
|
|
146
|
+
const issues = findings.filter((f) => f.level === "issue").length;
|
|
147
|
+
const warns = findings.filter((f) => f.level === "warn").length;
|
|
148
|
+
return `Summary: ${issues} issue(s), ${warns} warning(s). ${issues ? `Repairs: ${commandForm("doctor", { args: "--fix", env })} (install), ${commandForm("kanban", { env })} / reconcile (vault).` : "Vault and wiring look healthy."}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// What a reader gets when the renderer cannot draw the findings: each one
|
|
152
|
+
// per line, `level check message — file`, then the Summary line, still last.
|
|
153
|
+
export function doctorFallbackText(findings, { env = process.env } = {}) {
|
|
154
|
+
// A message's first line is prose; each further line is a lead ending with
|
|
155
|
+
// `:` or a command (the story "User-facing text says where to run…", rule 4
|
|
156
|
+
// as amended): the file stays with the first line's words, and every
|
|
157
|
+
// further line keeps a line of its own, as the message has it.
|
|
158
|
+
const lines = findings.map((f) => {
|
|
159
|
+
const [said, ...commands] = String(f.message).split("\n");
|
|
160
|
+
return [`${f.level} ${f.check} ${said}${f.file ? ` — ${f.file}` : ""}`, ...commands].join("\n");
|
|
161
|
+
});
|
|
162
|
+
return [...lines, ...(lines.length ? [""] : []), doctorSummaryLine(findings, { env })].join("\n") + "\n";
|
|
163
|
+
}
|
|
164
|
+
|
|
106
165
|
// The layout resolver and its constants, re-exported so hooks and scripts
|
|
107
166
|
// import one module (the layout ADR, 2026-09-06). The active harness's id,
|
|
108
|
-
// for the paths keyed by it (state/<id>/…).
|
|
109
|
-
|
|
167
|
+
// for the paths keyed by it (state/<id>/…). cachePaths is the user-level
|
|
168
|
+
// class beside it (layout spec contract 0): the shell fetch's scratch.
|
|
169
|
+
// whichOnPath, the PATH walk for a host binary, moved to harness.mjs beside
|
|
170
|
+
// hostPresence (the setup story, rule 5); its importers keep this name.
|
|
171
|
+
export {
|
|
172
|
+
layoutPaths,
|
|
173
|
+
cachePaths,
|
|
174
|
+
pickExisting,
|
|
175
|
+
LAYOUT,
|
|
176
|
+
RUNTIME_GITIGNORE_HEADER,
|
|
177
|
+
hostSettingsPath,
|
|
178
|
+
overlayId,
|
|
179
|
+
speakingHarness,
|
|
180
|
+
whichOnPath,
|
|
181
|
+
};
|
|
110
182
|
|
|
111
183
|
// A hook's payload carries the `cwd` of the session that fired it, and on a
|
|
112
184
|
// harness that exports no project-dir variable it is the only answer better
|
|
@@ -126,16 +198,35 @@ export function readOverlayAt(projectDir, id = overlayId()) {
|
|
|
126
198
|
// No id (no manifest at all): nothing is read, and the writer below refuses
|
|
127
199
|
// the same id rather than inventing a file name for it.
|
|
128
200
|
const path = id ? layoutPaths(projectDir).overlay(id) : null;
|
|
129
|
-
const out = {
|
|
201
|
+
const out = {
|
|
202
|
+
id: id || null,
|
|
203
|
+
path,
|
|
204
|
+
present: Boolean(path) && existsSync(path),
|
|
205
|
+
unparseable: false,
|
|
206
|
+
agents: { default: null, per_agent: {} },
|
|
207
|
+
rejected: [],
|
|
208
|
+
raw: null,
|
|
209
|
+
};
|
|
130
210
|
if (!out.present) return out;
|
|
131
211
|
let raw;
|
|
132
|
-
try {
|
|
133
|
-
|
|
212
|
+
try {
|
|
213
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
214
|
+
} catch {
|
|
215
|
+
out.unparseable = true;
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
219
|
+
out.unparseable = true;
|
|
220
|
+
return out;
|
|
221
|
+
}
|
|
134
222
|
out.raw = raw;
|
|
135
223
|
for (const k of Object.keys(raw)) if (k !== "agents") out.rejected.push(k);
|
|
136
224
|
const a = raw.agents;
|
|
137
225
|
if (a === undefined) return out;
|
|
138
|
-
if (!a || typeof a !== "object" || Array.isArray(a)) {
|
|
226
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) {
|
|
227
|
+
out.rejected.push("agents");
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
139
230
|
for (const k of Object.keys(a)) if (k !== "default" && k !== "per_agent") out.rejected.push(`agents.${k}`);
|
|
140
231
|
if (a.default !== undefined) {
|
|
141
232
|
if (a.default && typeof a.default === "object") {
|
|
@@ -146,7 +237,10 @@ export function readOverlayAt(projectDir, id = overlayId()) {
|
|
|
146
237
|
if (a.per_agent !== undefined) {
|
|
147
238
|
if (a.per_agent && typeof a.per_agent === "object" && !Array.isArray(a.per_agent)) {
|
|
148
239
|
for (const [name, v] of Object.entries(a.per_agent)) {
|
|
149
|
-
if (!v || typeof v !== "object") {
|
|
240
|
+
if (!v || typeof v !== "object") {
|
|
241
|
+
out.rejected.push(`agents.per_agent.${name}`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
150
244
|
for (const k of Object.keys(v)) if (k !== "model") out.rejected.push(`agents.per_agent.${name}.${k}`);
|
|
151
245
|
if (typeof v.model === "string" && v.model) out.agents.per_agent[name] = v.model;
|
|
152
246
|
}
|
|
@@ -171,14 +265,17 @@ export function writeOverlayAt(projectDir, id, agents) {
|
|
|
171
265
|
if (!id) throw new Error("writeOverlayAt: the overlay's harness id is required");
|
|
172
266
|
const path = layoutPaths(projectDir).overlay(id);
|
|
173
267
|
let raw = {};
|
|
174
|
-
try {
|
|
268
|
+
try {
|
|
269
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
270
|
+
} catch {}
|
|
175
271
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) raw = {};
|
|
176
272
|
const block = {};
|
|
177
273
|
if (agents.default) block.default = { model: agents.default };
|
|
178
274
|
const names = Object.keys(agents.per_agent || {}).sort();
|
|
179
275
|
if (names.length) block.per_agent = Object.fromEntries(names.map((n) => [n, { model: agents.per_agent[n] }]));
|
|
180
276
|
const next = { ...raw };
|
|
181
|
-
if (Object.keys(block).length) next.agents = block;
|
|
277
|
+
if (Object.keys(block).length) next.agents = block;
|
|
278
|
+
else delete next.agents;
|
|
182
279
|
ensureRuntimeDir(projectDir);
|
|
183
280
|
mkdirSync(dirname(path), { recursive: true });
|
|
184
281
|
writeFileAtomic(path, JSON.stringify(next, null, 2) + "\n", { sweep: false });
|
|
@@ -194,11 +291,13 @@ export function activeHarnessId() {
|
|
|
194
291
|
export function ensureGitignoreLines(file, lines, header = null) {
|
|
195
292
|
mkdirSync(dirname(file), { recursive: true });
|
|
196
293
|
let cur = "";
|
|
197
|
-
try {
|
|
294
|
+
try {
|
|
295
|
+
cur = readFileSync(file, "utf8");
|
|
296
|
+
} catch {}
|
|
198
297
|
const have = new Set(cur.split(/\r?\n/).map((l) => l.trim()));
|
|
199
298
|
const add = lines.filter((l) => !have.has(l));
|
|
200
299
|
if (!add.length) return false;
|
|
201
|
-
const prefix = cur ? (cur.endsWith("\n") ? cur : cur + "\n") :
|
|
300
|
+
const prefix = cur ? (cur.endsWith("\n") ? cur : cur + "\n") : header ? `# ${header}\n` : "";
|
|
202
301
|
writeFileSync(file, prefix + add.join("\n") + "\n", "utf8");
|
|
203
302
|
return true;
|
|
204
303
|
}
|
|
@@ -217,14 +316,37 @@ export function moveStateDir(from, to, within) {
|
|
|
217
316
|
mkdirSync(to, { recursive: true });
|
|
218
317
|
for (const name of readdirSync(from)) {
|
|
219
318
|
if (name === ".gitignore") continue;
|
|
220
|
-
const src = join(from, name),
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
319
|
+
const src = join(from, name),
|
|
320
|
+
dst = join(to, name);
|
|
321
|
+
let st;
|
|
322
|
+
try {
|
|
323
|
+
st = lstatSync(src);
|
|
324
|
+
} catch {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
if (st.isSymbolicLink()) {
|
|
328
|
+
out.dropped.push(name);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (!existsSync(dst)) {
|
|
332
|
+
movePath(src, dst, within);
|
|
333
|
+
out.moved.push(name);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (st.isDirectory() || name.startsWith(".")) {
|
|
337
|
+
rmSync(src, { recursive: true, force: true });
|
|
338
|
+
out.kept.push(name);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
225
341
|
const newer = st.mtimeMs > statSync(dst).mtimeMs;
|
|
226
|
-
if (newer) {
|
|
227
|
-
|
|
342
|
+
if (newer) {
|
|
343
|
+
rmSync(dst, { force: true });
|
|
344
|
+
movePath(src, dst, within);
|
|
345
|
+
out.replaced.push(name);
|
|
346
|
+
} else {
|
|
347
|
+
rmSync(src, { force: true });
|
|
348
|
+
out.kept.push(name);
|
|
349
|
+
}
|
|
228
350
|
}
|
|
229
351
|
return out;
|
|
230
352
|
}
|
|
@@ -233,10 +355,16 @@ export function moveStateDir(from, to, within) {
|
|
|
233
355
|
// the legacy file is removed; an absent side is fine.
|
|
234
356
|
export function mergeEntryLog(from, to, within) {
|
|
235
357
|
if (!existsSync(from)) return "absent";
|
|
236
|
-
const inside = (p) => {
|
|
358
|
+
const inside = (p) => {
|
|
359
|
+
const r = relative(resolve(within), resolve(p));
|
|
360
|
+
return r !== "" && !r.startsWith("..") && !isAbsolute(r);
|
|
361
|
+
};
|
|
237
362
|
if (!inside(from) || !inside(to)) throw new Error(`mergeEntryLog: ${from} → ${to} leaves ${within}`);
|
|
238
363
|
const old = readFileSync(from, "utf8");
|
|
239
|
-
let cur = "";
|
|
364
|
+
let cur = "";
|
|
365
|
+
try {
|
|
366
|
+
cur = readFileSync(to, "utf8");
|
|
367
|
+
} catch {}
|
|
240
368
|
mkdirSync(dirname(to), { recursive: true });
|
|
241
369
|
const glue = old && !old.endsWith("\n") ? "\n" : "";
|
|
242
370
|
writeFileAtomic(to, old + glue + cur, { sweep: false });
|
|
@@ -256,7 +384,10 @@ export function removeInside(path, within, { recursive = false } = {}) {
|
|
|
256
384
|
}
|
|
257
385
|
|
|
258
386
|
export function movePath(from, to, within) {
|
|
259
|
-
const inside = (p) => {
|
|
387
|
+
const inside = (p) => {
|
|
388
|
+
const r = relative(resolve(within), resolve(p));
|
|
389
|
+
return r !== "" && !r.startsWith("..") && !isAbsolute(r);
|
|
390
|
+
};
|
|
260
391
|
if (!inside(from) || !inside(to)) throw new Error(`movePath: ${from} → ${to} leaves ${within}`);
|
|
261
392
|
if (!existsSync(from)) return "absent";
|
|
262
393
|
if (existsSync(to)) return "target-exists";
|
|
@@ -276,7 +407,7 @@ export function readConfigAt(projectDir) {
|
|
|
276
407
|
if (!existsSync(p)) return null;
|
|
277
408
|
try {
|
|
278
409
|
return JSON.parse(readFileSync(p, "utf8"));
|
|
279
|
-
} catch
|
|
410
|
+
} catch {
|
|
280
411
|
return null;
|
|
281
412
|
}
|
|
282
413
|
}
|
|
@@ -298,7 +429,11 @@ export function isMain(metaUrl) {
|
|
|
298
429
|
if (!argv1) return false;
|
|
299
430
|
const here = fileURLToPath(metaUrl);
|
|
300
431
|
if (resolve(argv1) === here) return true;
|
|
301
|
-
try {
|
|
432
|
+
try {
|
|
433
|
+
return realpathSync(argv1) === here;
|
|
434
|
+
} catch {
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
302
437
|
}
|
|
303
438
|
|
|
304
439
|
// ─── Atomic file writes (spec: atomic-regeneration-of-derived-views) ──
|
|
@@ -323,7 +458,9 @@ export function writeFileAtomic(p, content, { sweep = true } = {}) {
|
|
|
323
458
|
writeFileSync(tmp, content, "utf8");
|
|
324
459
|
renameSync(tmp, p);
|
|
325
460
|
} catch (e) {
|
|
326
|
-
try {
|
|
461
|
+
try {
|
|
462
|
+
unlinkSync(tmp);
|
|
463
|
+
} catch {}
|
|
327
464
|
throw e;
|
|
328
465
|
}
|
|
329
466
|
}
|
|
@@ -348,22 +485,36 @@ export function writeExclusiveMetadata(fd, value) {
|
|
|
348
485
|
// numeric pid, `.tmp`) can never match `.gitignore` and friends.
|
|
349
486
|
function sweepOrphanTemps(dir) {
|
|
350
487
|
let names;
|
|
351
|
-
try {
|
|
488
|
+
try {
|
|
489
|
+
names = readdirSync(dir);
|
|
490
|
+
} catch {
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
352
493
|
for (const n of names) {
|
|
353
494
|
const m = n.match(/^\..+\.(\d+)\.tmp$/);
|
|
354
495
|
if (!m) continue;
|
|
355
496
|
const pid = parseInt(m[1], 10);
|
|
356
497
|
if (!pid || pid === process.pid) continue;
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
try { unlinkSync(join(dir, n)); } catch {}
|
|
362
|
-
}
|
|
498
|
+
if (!pidAlive(pid)) {
|
|
499
|
+
try {
|
|
500
|
+
unlinkSync(join(dir, n));
|
|
501
|
+
} catch {}
|
|
363
502
|
}
|
|
364
503
|
}
|
|
365
504
|
}
|
|
366
505
|
|
|
506
|
+
// The sweeps' one liveness rule: signal 0 returns ⇒ alive; EPERM ⇒ alive but
|
|
507
|
+
// not ours; only ESRCH is dead. Shared by the temp sweep above and the shell
|
|
508
|
+
// fetch's scratch sweep (fetch-shell.mjs), so the two cannot drift.
|
|
509
|
+
export function pidAlive(pid) {
|
|
510
|
+
try {
|
|
511
|
+
process.kill(pid, 0);
|
|
512
|
+
return true;
|
|
513
|
+
} catch (e) {
|
|
514
|
+
return e?.code !== "ESRCH";
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
367
518
|
// ─── Installed-plugin resolution ───────────────────────────────────────
|
|
368
519
|
|
|
369
520
|
// The harness's config directory. Almost always ~/.claude, but the harness's
|
|
@@ -378,8 +529,12 @@ export function claudeHome(home = homedir()) {
|
|
|
378
529
|
// "which release line" (the layout window's sunset); wrong for "which build is
|
|
379
530
|
// newer" — that is cmpPrecedence.
|
|
380
531
|
export function cmpVersion(a, b) {
|
|
381
|
-
const A = String(a || "0")
|
|
382
|
-
|
|
532
|
+
const A = String(a || "0")
|
|
533
|
+
.split(".")
|
|
534
|
+
.map((n) => parseInt(n, 10) || 0);
|
|
535
|
+
const B = String(b || "0")
|
|
536
|
+
.split(".")
|
|
537
|
+
.map((n) => parseInt(n, 10) || 0);
|
|
383
538
|
for (let i = 0; i < 3; i++) if ((A[i] || 0) !== (B[i] || 0)) return (A[i] || 0) - (B[i] || 0);
|
|
384
539
|
return 0;
|
|
385
540
|
}
|
|
@@ -392,18 +547,27 @@ export function cmpPrecedence(a, b) {
|
|
|
392
547
|
const parse = (v) => {
|
|
393
548
|
const s = String(v || "0").split("+")[0];
|
|
394
549
|
const dash = s.indexOf("-");
|
|
395
|
-
return {
|
|
550
|
+
return {
|
|
551
|
+
core: (dash < 0 ? s : s.slice(0, dash)).split(".").map((n) => parseInt(n, 10) || 0),
|
|
552
|
+
pre: dash < 0 ? [] : s.slice(dash + 1).split("."),
|
|
553
|
+
};
|
|
396
554
|
};
|
|
397
|
-
const A = parse(a),
|
|
555
|
+
const A = parse(a),
|
|
556
|
+
B = parse(b);
|
|
398
557
|
for (let i = 0; i < 3; i++) if ((A.core[i] || 0) !== (B.core[i] || 0)) return (A.core[i] || 0) - (B.core[i] || 0);
|
|
399
558
|
// A release outranks every candidate for it.
|
|
400
559
|
if (!A.pre.length || !B.pre.length) return B.pre.length - A.pre.length;
|
|
401
560
|
for (let i = 0; i < Math.max(A.pre.length, B.pre.length); i++) {
|
|
402
561
|
if (i >= A.pre.length) return -1;
|
|
403
562
|
if (i >= B.pre.length) return 1;
|
|
404
|
-
const x = A.pre[i],
|
|
405
|
-
|
|
406
|
-
|
|
563
|
+
const x = A.pre[i],
|
|
564
|
+
y = B.pre[i];
|
|
565
|
+
const nx = /^\d+$/.test(x),
|
|
566
|
+
ny = /^\d+$/.test(y);
|
|
567
|
+
if (nx && ny) {
|
|
568
|
+
if (Number(x) !== Number(y)) return Number(x) - Number(y);
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
407
571
|
// A numeric identifier ranks below an alphanumeric one.
|
|
408
572
|
if (nx !== ny) return nx ? -1 : 1;
|
|
409
573
|
if (x !== y) return x < y ? -1 : 1;
|
|
@@ -428,16 +592,24 @@ export function cmpPrecedence(a, b) {
|
|
|
428
592
|
// The host's enablement of a plugin, user settings first and the project's
|
|
429
593
|
// settings over it (a project may silence a user-scope plugin). Absent → true.
|
|
430
594
|
export function pluginEnabled(key, home = homedir(), projectDir = null) {
|
|
431
|
-
const read = (p) => {
|
|
595
|
+
const read = (p) => {
|
|
596
|
+
try {
|
|
597
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
598
|
+
} catch {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
};
|
|
432
602
|
let enabled = true;
|
|
433
603
|
const user = read(join(claudeHome(home), "settings.json"));
|
|
434
|
-
if (user && user.enabledPlugins && Object.hasOwn(user.enabledPlugins, key))
|
|
604
|
+
if (user && user.enabledPlugins && Object.hasOwn(user.enabledPlugins, key))
|
|
605
|
+
enabled = user.enabledPlugins[key] !== false;
|
|
435
606
|
if (projectDir) {
|
|
436
607
|
// The committed project file, then the checkout's local one over it — the
|
|
437
608
|
// host's own precedence (measured 2026-09-05, --scope local).
|
|
438
609
|
for (const f of ["settings.json", "settings.local.json"]) {
|
|
439
610
|
const proj = read(join(projectDir, harnessProjectConfigDir(), f));
|
|
440
|
-
if (proj && proj.enabledPlugins && Object.hasOwn(proj.enabledPlugins, key))
|
|
611
|
+
if (proj && proj.enabledPlugins && Object.hasOwn(proj.enabledPlugins, key))
|
|
612
|
+
enabled = proj.enabledPlugins[key] !== false;
|
|
441
613
|
}
|
|
442
614
|
}
|
|
443
615
|
return enabled;
|
|
@@ -474,18 +646,135 @@ export function installedPluginEntries(home = homedir(), projectDir = null) {
|
|
|
474
646
|
return out;
|
|
475
647
|
}
|
|
476
648
|
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
649
|
+
// Contract 12's selection (the layout spec, as amended 2026-10-03): the copies
|
|
650
|
+
// of ours the host loads for this project — present, enabled for it, and this
|
|
651
|
+
// checkout's own row or a row with no project (user scope), never another
|
|
652
|
+
// checkout's. This checkout's row first, then the most recently updated. Real
|
|
653
|
+
// paths: the host records the project's cwd as one, and /var against
|
|
654
|
+
// /private/var would otherwise drop the project's own row. One filter for
|
|
655
|
+
// doctor's layout remedy and the convergence story's running copies (the
|
|
656
|
+
// converge story's rule 6), so the two cannot disagree about what a project
|
|
657
|
+
// runs. `own` marks this checkout's row.
|
|
658
|
+
export function projectCopies(home = homedir(), projectDir) {
|
|
659
|
+
const real = (x) => {
|
|
660
|
+
try {
|
|
661
|
+
return realpathSync.native(x);
|
|
662
|
+
} catch {
|
|
663
|
+
return resolve(x);
|
|
486
664
|
}
|
|
665
|
+
};
|
|
666
|
+
const here = real(projectDir);
|
|
667
|
+
const mine = (e) => Boolean(e.projectPath) && real(e.projectPath) === here;
|
|
668
|
+
return installedPluginEntries(home, projectDir)
|
|
669
|
+
.filter((e) => e.present && e.enabled && (!e.projectPath || mine(e)))
|
|
670
|
+
.map((e) => ({ ...e, own: mine(e) }))
|
|
671
|
+
.sort((a, b) => Number(b.own) - Number(a.own) || b.at - a.at);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// The same selection, one row per plugin key: the row the host loads for this
|
|
675
|
+
// project — this checkout's own row over a user-scope row of the same key,
|
|
676
|
+
// else the most recent. What "a copy enabled for this project" means for the
|
|
677
|
+
// competing copies the registration silences (surfaces.mjs, contract 13) and
|
|
678
|
+
// for the copies the convergence story's rule 6 compares, so the stop and the
|
|
679
|
+
// deferral of a newer copy always see the same (key, version) pairs.
|
|
680
|
+
export function projectCopiesByKey(home = homedir(), projectDir) {
|
|
681
|
+
const seen = new Set();
|
|
682
|
+
return projectCopies(home, projectDir).filter((e) => !seen.has(e.key) && seen.add(e.key));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Whether a registry copy can run the command named for it. `--no-register`
|
|
686
|
+
// arrived in 0.28.0-rc.3, and rc.1 and rc.2 parse strictly, so the command
|
|
687
|
+
// exits 2 on the flag and writes nothing (the layout spec, contract 12 as
|
|
688
|
+
// amended after the rc.3 tag). Read from the copy's own parse table, never
|
|
689
|
+
// from its version: a never-published 0.28.0 build ranks above rc.3 and lacks
|
|
690
|
+
// the flag, while a copy labelled rc.2 taken from main at 418448e has it. A
|
|
691
|
+
// file read, because this runs in the SessionStart subset: never an import()
|
|
692
|
+
// of the copy, never a spawn. Add files to the read, never drop cli.mjs:
|
|
693
|
+
// released copies keep their parse there. Moved here from doctor.mjs (the
|
|
694
|
+
// converge story's rule 5), which re-exports it: doctor's layout remedy and
|
|
695
|
+
// the installer's keep read one function.
|
|
696
|
+
export function takesNoRegister(copy) {
|
|
697
|
+
try {
|
|
698
|
+
return readFileSync(join(copy, "scripts", "cli.mjs"), "utf8").includes('"no-register"');
|
|
699
|
+
} catch {
|
|
700
|
+
return false;
|
|
487
701
|
}
|
|
488
|
-
|
|
702
|
+
}
|
|
703
|
+
// …and whether its CLI takes --verbose, the same way: the flag arrived in
|
|
704
|
+
// 0.29.0, after --no-register, and a copy between them (rc.3 to 0.28.x)
|
|
705
|
+
// parses strictly, so a command naming it would exit 2. Read before DONE names
|
|
706
|
+
// a kept copy's own `plan --verbose`.
|
|
707
|
+
export function takesVerbose(copy) {
|
|
708
|
+
try {
|
|
709
|
+
return /\bverbose\b/.test(readFileSync(join(copy, "scripts", "cli.mjs"), "utf8"));
|
|
710
|
+
} catch {
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Contract 12's selection with its capability read (the layout spec, contract
|
|
716
|
+
// 12 as amended 2026-10-03; the converge story's rule 5): the first of
|
|
717
|
+
// projectCopies — this checkout's own row, then user-scope rows by recency,
|
|
718
|
+
// never another checkout's — and whether its own bin can run the remedy form
|
|
719
|
+
// (`missing`: "bin" for a copy with no bin/projectstore.mjs, 0.27.x;
|
|
720
|
+
// "no-register" for one whose CLI predates the flag, rc.1 and rc.2; null when
|
|
721
|
+
// it can). `only` limits the rows to one install channel before the first is
|
|
722
|
+
// taken: the keep takes "marketplace", so the npm registration's copy is never
|
|
723
|
+
// the kept one. Doctor's layout remedy and the installer's keep call this one
|
|
724
|
+
// function, so the two never disagree about which copy a project runs.
|
|
725
|
+
export function selectCopy(home = homedir(), projectDir, { harness = sourceHarness(), only = null } = {}) {
|
|
726
|
+
const copy = projectCopies(home, projectDir).find((e) => !only || installChannel(e.path, { home, harness }) === only);
|
|
727
|
+
if (!copy) return null;
|
|
728
|
+
const bin = join(copy.path, "bin", "projectstore.mjs");
|
|
729
|
+
const missing = !existsSync(bin) ? "bin" : !takesNoRegister(copy.path) ? "no-register" : null;
|
|
730
|
+
return { ...copy, bin, missing, channel: installChannel(copy.path, { home, harness }) };
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// The advice for a marketplace copy that cannot run what is named for it, in
|
|
734
|
+
// that copy's own channel: the host's update, then a restart. `cannot` says
|
|
735
|
+
// what it cannot do, `then` what follows the update. One text for doctor's
|
|
736
|
+
// layout remedy and the keep's skip row and DONE line.
|
|
737
|
+
export function copyUpdateAdvice(copy, { cannot, then }) {
|
|
738
|
+
const v = copy?.version ? ` (${copy.version})` : "";
|
|
739
|
+
return `This project's projectstore plugin${v} ${cannot}: update it first (in Claude Code: /plugin marketplace update, then /plugin update, then restart), ${then}`;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// The mark a channel switch leaves in this checkout's machine-local binding
|
|
743
|
+
// (the converge story's rules 2–4): `channel_switch: { by, at }`, two strings.
|
|
744
|
+
// It says this checkout chose the npm install; it lists no copies — the
|
|
745
|
+
// silenced set lives in our manifest and the checkout's local settings.
|
|
746
|
+
// Doctor drops contract 13's "if moving to npm was meant, ignore this" info
|
|
747
|
+
// for a marked checkout, the installer plans no second mark, and uninstall
|
|
748
|
+
// removes the key with the registration. `bind --inherit` never copies it.
|
|
749
|
+
export const CHANNEL_SWITCH_KEY = "channel_switch";
|
|
750
|
+
const stampAt = (cfg, key) => {
|
|
751
|
+
const m = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? cfg[key] : null;
|
|
752
|
+
return m && typeof m === "object" && !Array.isArray(m) && typeof m.by === "string" && typeof m.at === "string"
|
|
753
|
+
? m
|
|
754
|
+
: null;
|
|
755
|
+
};
|
|
756
|
+
export function channelMark(cfg) {
|
|
757
|
+
return stampAt(cfg, CHANNEL_SWITCH_KEY);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Its counterpart for the other answer (the setup story, rule 6): the keep of
|
|
761
|
+
// the git marketplace, `channel_keep: { by, at }`, recorded when setup's
|
|
762
|
+
// channel question was answered keep. Setup's question then defaults to keep,
|
|
763
|
+
// and a run without a terminal reads it as `--channel git`. Per checkout, as
|
|
764
|
+
// the mark is: `bind --inherit` never copies it. The latest answer wins: a
|
|
765
|
+
// later switch removes it, and recording it removes the mark, so the two
|
|
766
|
+
// never both stay after a run.
|
|
767
|
+
export const CHANNEL_KEEP_KEY = "channel_keep";
|
|
768
|
+
export function channelKeep(cfg) {
|
|
769
|
+
return stampAt(cfg, CHANNEL_KEEP_KEY);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// A switch read from the environment the way contract 9 reads CI: set, and
|
|
773
|
+
// not empty, "0" or "false" (any case). One reading for CI and for
|
|
774
|
+
// PROJECTSTORE_NO_UPDATE_CHECK (the converge story's rule 8).
|
|
775
|
+
export function envFlag(env, name) {
|
|
776
|
+
const v = env ? env[name] : undefined;
|
|
777
|
+
return v !== undefined && v !== null && v !== "" && !["0", "false"].includes(String(v).toLowerCase());
|
|
489
778
|
}
|
|
490
779
|
|
|
491
780
|
// The files a package ships, as the release gate defines them — package.json
|
|
@@ -494,15 +783,26 @@ export function whichOnPath(name, env = process.env) {
|
|
|
494
783
|
// path outside the tree). Returns the relative paths copied, sorted.
|
|
495
784
|
export function packageFiles(root) {
|
|
496
785
|
let pkg;
|
|
497
|
-
try {
|
|
786
|
+
try {
|
|
787
|
+
pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
788
|
+
} catch {
|
|
789
|
+
return [];
|
|
790
|
+
}
|
|
498
791
|
const out = new Set(["package.json"]);
|
|
499
792
|
for (const n of readdirSync(root)) if (/^(README|LICEN[CS]E)(\.|$)/i.test(n)) out.add(n);
|
|
500
793
|
const walk = (rel) => {
|
|
501
794
|
const abs = join(root, rel);
|
|
502
|
-
let st;
|
|
795
|
+
let st;
|
|
796
|
+
try {
|
|
797
|
+
st = lstatSync(abs);
|
|
798
|
+
} catch {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
503
801
|
if (st.isSymbolicLink()) return;
|
|
504
|
-
if (st.isDirectory()) {
|
|
505
|
-
|
|
802
|
+
if (st.isDirectory()) {
|
|
803
|
+
for (const n of readdirSync(abs))
|
|
804
|
+
if (n !== "node_modules" && !n.startsWith(".DS_Store")) walk(rel ? `${rel}/${n}` : n);
|
|
805
|
+
} else out.add(rel);
|
|
506
806
|
};
|
|
507
807
|
for (const f of pkg.files || []) walk(f.replace(/\/+$/, ""));
|
|
508
808
|
return [...out].sort();
|
|
@@ -512,8 +812,12 @@ export function packageFiles(root) {
|
|
|
512
812
|
// ours (install spec contract 13, amended 2026-09-05). The caller has already
|
|
513
813
|
// proven the manifest is ours; this refuses anything outside the harness home.
|
|
514
814
|
export function removeOwnTree(dir, home = homedir()) {
|
|
515
|
-
const norm = (s) =>
|
|
516
|
-
|
|
815
|
+
const norm = (s) =>
|
|
816
|
+
String(s || "")
|
|
817
|
+
.replace(/\\/g, "/")
|
|
818
|
+
.replace(/\/+$/, "");
|
|
819
|
+
if (!norm(dir).startsWith(norm(claudeHome(home)) + "/"))
|
|
820
|
+
throw new Error(`removeOwnTree: ${dir} is not under the harness home`);
|
|
517
821
|
rmSync(dir, { recursive: true, force: true });
|
|
518
822
|
}
|
|
519
823
|
|
|
@@ -522,7 +826,7 @@ export function copyPackageTree(from, to) {
|
|
|
522
826
|
for (const rel of files) {
|
|
523
827
|
const dst = join(to, rel);
|
|
524
828
|
mkdirSync(dirname(dst), { recursive: true });
|
|
525
|
-
|
|
829
|
+
copyFileSync(join(from, rel), dst);
|
|
526
830
|
}
|
|
527
831
|
return files;
|
|
528
832
|
}
|
|
@@ -532,10 +836,17 @@ export function treeFiles(dir) {
|
|
|
532
836
|
const out = [];
|
|
533
837
|
const walk = (rel) => {
|
|
534
838
|
const abs = rel ? join(dir, rel) : dir;
|
|
535
|
-
let st;
|
|
839
|
+
let st;
|
|
840
|
+
try {
|
|
841
|
+
st = lstatSync(abs);
|
|
842
|
+
} catch {
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
536
845
|
if (st.isSymbolicLink()) return;
|
|
537
|
-
if (st.isDirectory()) {
|
|
538
|
-
|
|
846
|
+
if (st.isDirectory()) {
|
|
847
|
+
for (const n of readdirSync(abs))
|
|
848
|
+
if (n !== "node_modules" && !n.startsWith(".DS_Store")) walk(rel ? `${rel}/${n}` : n);
|
|
849
|
+
} else out.push(rel);
|
|
539
850
|
};
|
|
540
851
|
walk("");
|
|
541
852
|
return out.sort();
|
|
@@ -549,7 +860,11 @@ export function filesDigest(root, files) {
|
|
|
549
860
|
const h = createHash("sha256");
|
|
550
861
|
for (const rel of files) {
|
|
551
862
|
h.update(rel + "\n");
|
|
552
|
-
h.update(
|
|
863
|
+
h.update(
|
|
864
|
+
createHash("sha256")
|
|
865
|
+
.update(readFileSync(join(root, rel)))
|
|
866
|
+
.digest("hex") + "\n",
|
|
867
|
+
);
|
|
553
868
|
}
|
|
554
869
|
return { count: files.length, sha256: h.digest("hex") };
|
|
555
870
|
}
|
|
@@ -562,8 +877,12 @@ export const treeDigest = (dir) => filesDigest(dir, treeFiles(dir));
|
|
|
562
877
|
// contract 4′). An existing directory is removed only after the stage is
|
|
563
878
|
// complete — and only by the caller's proof that it is ours.
|
|
564
879
|
export function writeOwnTree(dir, { from, subdir, manifestRel, manifest, home = homedir() }) {
|
|
565
|
-
const norm = (s) =>
|
|
566
|
-
|
|
880
|
+
const norm = (s) =>
|
|
881
|
+
String(s || "")
|
|
882
|
+
.replace(/\\/g, "/")
|
|
883
|
+
.replace(/\/+$/, "");
|
|
884
|
+
if (!norm(dir).startsWith(norm(claudeHome(home)) + "/"))
|
|
885
|
+
throw new Error(`writeOwnTree: ${dir} is not under the harness home`);
|
|
567
886
|
const stage = dir + ".staging";
|
|
568
887
|
rmSync(stage, { recursive: true, force: true });
|
|
569
888
|
mkdirSync(join(stage, subdir), { recursive: true });
|
|
@@ -580,27 +899,49 @@ export function writeOwnTree(dir, { from, subdir, manifestRel, manifest, home =
|
|
|
580
899
|
// entry is re-checked as a regular file at apply time, so a symlink swap cannot
|
|
581
900
|
// escape the source root. The previous directory is retained until the caller
|
|
582
901
|
// verifies the host cache and returns the backup path for commit/rollback.
|
|
583
|
-
export function stagePortableMarketplace(
|
|
584
|
-
|
|
585
|
-
|
|
902
|
+
export function stagePortableMarketplace(
|
|
903
|
+
dir,
|
|
904
|
+
{
|
|
905
|
+
from,
|
|
906
|
+
files,
|
|
907
|
+
subdir,
|
|
908
|
+
catalogRel,
|
|
909
|
+
catalog,
|
|
910
|
+
ownershipRel,
|
|
911
|
+
ownership,
|
|
912
|
+
homeBase,
|
|
913
|
+
token = `${process.pid}-${randomUUID()}`,
|
|
914
|
+
},
|
|
915
|
+
) {
|
|
916
|
+
const norm = (s) =>
|
|
917
|
+
String(s || "")
|
|
918
|
+
.replace(/\\/g, "/")
|
|
919
|
+
.replace(/\/+$/, "");
|
|
920
|
+
if (!norm(dir).startsWith(norm(homeBase) + "/"))
|
|
921
|
+
throw new Error(`stagePortableMarketplace: ${dir} is not under ${homeBase}`);
|
|
586
922
|
const stage = `${dir}.staging-${token}`;
|
|
587
923
|
const backup = existsSync(dir) ? `${dir}.previous-${token}` : null;
|
|
588
924
|
rmSync(stage, { recursive: true, force: true });
|
|
589
925
|
for (const rel of files) {
|
|
590
926
|
const src = join(from, rel);
|
|
591
927
|
const st = lstatSync(src);
|
|
592
|
-
if (!st.isFile() || st.isSymbolicLink())
|
|
928
|
+
if (!st.isFile() || st.isSymbolicLink())
|
|
929
|
+
throw new Error(`portable payload changed under the plan: ${rel} is not a regular file`);
|
|
593
930
|
const dst = join(stage, subdir, rel);
|
|
594
931
|
mkdirSync(dirname(dst), { recursive: true });
|
|
595
|
-
|
|
932
|
+
copyFileSync(src, dst);
|
|
596
933
|
}
|
|
597
934
|
mkdirSync(dirname(join(stage, catalogRel)), { recursive: true });
|
|
598
935
|
mkdirSync(dirname(join(stage, ownershipRel)), { recursive: true });
|
|
599
936
|
writeFileAtomic(join(stage, catalogRel), JSON.stringify(catalog, null, 2) + "\n", { sweep: false });
|
|
600
937
|
writeFileAtomic(join(stage, ownershipRel), JSON.stringify(ownership, null, 2) + "\n", { sweep: false });
|
|
601
938
|
if (backup) renameSync(dir, backup);
|
|
602
|
-
try {
|
|
603
|
-
|
|
939
|
+
try {
|
|
940
|
+
renameSync(stage, dir);
|
|
941
|
+
} catch (e) {
|
|
942
|
+
if (backup && !existsSync(dir)) renameSync(backup, dir);
|
|
943
|
+
throw e;
|
|
944
|
+
}
|
|
604
945
|
return { stage, backup };
|
|
605
946
|
}
|
|
606
947
|
|
|
@@ -615,17 +956,28 @@ export function rollbackPortableMarketplace(dir, backup = null) {
|
|
|
615
956
|
return dir;
|
|
616
957
|
}
|
|
617
958
|
|
|
618
|
-
|
|
619
|
-
|
|
959
|
+
// `maxRetries` is rmSync's own: a tree another process may still be writing
|
|
960
|
+
// into (an npm the fetch budget stopped) can fail with ENOTEMPTY or EBUSY
|
|
961
|
+
// once and succeed on the next pass. The path guard holds whatever it is.
|
|
962
|
+
export function removeTreeUnder(dir, homeBase, { maxRetries = 0 } = {}) {
|
|
963
|
+
const norm = (s) =>
|
|
964
|
+
String(s || "")
|
|
965
|
+
.replace(/\\/g, "/")
|
|
966
|
+
.replace(/\/+$/, "");
|
|
620
967
|
if (!norm(dir).startsWith(norm(homeBase) + "/")) throw new Error(`removeTreeUnder: ${dir} is not under ${homeBase}`);
|
|
621
|
-
rmSync(dir, { recursive: true, force: true });
|
|
968
|
+
rmSync(dir, { recursive: true, force: true, maxRetries });
|
|
622
969
|
}
|
|
623
970
|
|
|
624
971
|
export function installedPluginRoot(home = homedir(), preferFamily = null) {
|
|
625
972
|
try {
|
|
626
973
|
const found = installedPluginEntries(home)
|
|
627
974
|
.filter((e) => e.present && e.enabled !== false)
|
|
628
|
-
.map((e) => ({
|
|
975
|
+
.map((e) => ({
|
|
976
|
+
path: e.path,
|
|
977
|
+
version: e.version,
|
|
978
|
+
same: preferFamily && dirname(e.path) === preferFamily ? 1 : 0,
|
|
979
|
+
at: e.at,
|
|
980
|
+
}));
|
|
629
981
|
// Family is a filter, not a tiebreak: when the caller came from a known
|
|
630
982
|
// marketplace, an install from a DIFFERENT one is not a newer copy of the
|
|
631
983
|
// same plugin — it is someone else's fork, and we do not execute it.
|
|
@@ -646,13 +998,27 @@ export function installedPluginRoot(home = homedir(), preferFamily = null) {
|
|
|
646
998
|
// took its own copy for a checkout and planned the package's registration
|
|
647
999
|
// (measured 2026-10-03, the critic of the layout spec's contract 12 amendment).
|
|
648
1000
|
function underPluginCache(root, home = homedir()) {
|
|
649
|
-
const norm = (s) =>
|
|
1001
|
+
const norm = (s) =>
|
|
1002
|
+
String(s || "")
|
|
1003
|
+
.replace(/\\/g, "/")
|
|
1004
|
+
.replace(/\/+$/, "");
|
|
650
1005
|
const cache = join(claudeHome(home), "plugins", "cache");
|
|
651
|
-
const below = (r, c) => {
|
|
1006
|
+
const below = (r, c) => {
|
|
1007
|
+
const a = norm(r),
|
|
1008
|
+
b = norm(c);
|
|
1009
|
+
return a.startsWith(b + "/") ? a.slice(b.length + 1) : null;
|
|
1010
|
+
};
|
|
652
1011
|
const direct = below(root, cache);
|
|
653
1012
|
if (direct !== null || !root) return direct;
|
|
654
|
-
const real = (x) => {
|
|
655
|
-
|
|
1013
|
+
const real = (x) => {
|
|
1014
|
+
try {
|
|
1015
|
+
return realpathSync(x);
|
|
1016
|
+
} catch {
|
|
1017
|
+
return null;
|
|
1018
|
+
}
|
|
1019
|
+
};
|
|
1020
|
+
const rr = real(root),
|
|
1021
|
+
rc = real(cache);
|
|
656
1022
|
return rr && rc ? below(rr, rc) : null;
|
|
657
1023
|
}
|
|
658
1024
|
|
|
@@ -668,10 +1034,13 @@ export function isEphemeralRoot(root) {
|
|
|
668
1034
|
}
|
|
669
1035
|
|
|
670
1036
|
// Which channel the copy at `root` came through. A remedy that re-runs the
|
|
671
|
-
// installer has to ask this first, because the package's
|
|
672
|
-
// plugin through
|
|
673
|
-
//
|
|
674
|
-
// spec, contract 12 as amended
|
|
1037
|
+
// installer has to ask this first, because the npm package's upgrade
|
|
1038
|
+
// registers the plugin through the npm channel: run for a git-marketplace
|
|
1039
|
+
// user without --no-register, it adds a second copy and turns the installed
|
|
1040
|
+
// one off for the checkout (the layout spec, contract 12 as amended
|
|
1041
|
+
// 2026-10-03). So the remedy names the copy's own bin for a host copy of
|
|
1042
|
+
// another channel, and the core's form only for the npm registration's. The
|
|
1043
|
+
// answers:
|
|
675
1044
|
// - "registration": the host's cache copy of the registration marketplace the
|
|
676
1045
|
// manifest names, i.e. the package's own channel;
|
|
677
1046
|
// - "marketplace": any other host cache copy (the git marketplace, a fork);
|
|
@@ -697,10 +1066,20 @@ export function installChannel(root, { home = homedir(), harness = sourceHarness
|
|
|
697
1066
|
// wrote an AGENTS.md into it (2026-10-05). bind refuses the same way
|
|
698
1067
|
// (NO_PROJECT, binding.mjs).
|
|
699
1068
|
export function projectDirRefusal(dir) {
|
|
700
|
-
try {
|
|
1069
|
+
try {
|
|
1070
|
+
if (statSync(dir).isDirectory()) return null;
|
|
1071
|
+
} catch {}
|
|
701
1072
|
return `no such project directory: ${JSON.stringify(String(dir))} — nothing is written`;
|
|
702
1073
|
}
|
|
703
1074
|
|
|
1075
|
+
// Where a printed command runs, named in prose (the story "User-facing text
|
|
1076
|
+
// says where to run and names no project path and no shell", rule 1): the
|
|
1077
|
+
// command carries no --project, so a run's own output names the directory,
|
|
1078
|
+
// quoted as the refusal above quotes it — a trailing comma or space shows.
|
|
1079
|
+
export function fromTerminalIn(dir) {
|
|
1080
|
+
return `from a terminal in ${JSON.stringify(String(dir))}`;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
704
1083
|
// ─── Status line wiring (SessionStart-managed) ─────────────────────────
|
|
705
1084
|
//
|
|
706
1085
|
// The Claude Code statusLine slot is single and NOT plugin-declarable, so
|
|
@@ -714,7 +1093,8 @@ export function projectDirRefusal(dir) {
|
|
|
714
1093
|
// PREVIOUS session start, one restart behind every update. The launcher path
|
|
715
1094
|
// never changes, and it resolves the installed plugin at render time, so
|
|
716
1095
|
// `/plugin update` + `/reload-plugins` show up immediately. Dev checkouts and
|
|
717
|
-
// --plugin-dir roots carry no version, so
|
|
1096
|
+
// --plugin-dir roots carry no version, so install wires those directly; a
|
|
1097
|
+
// session run from one keeps whatever launcher entry install wrote.
|
|
718
1098
|
//
|
|
719
1099
|
// Idempotent (writes only when the value changes); never clobbers a foreign
|
|
720
1100
|
// statusLine; bails on an unparseable settings file. Returns a status string,
|
|
@@ -731,7 +1111,9 @@ export function legacyStatusLineLauncherPath(projectDir) {
|
|
|
731
1111
|
}
|
|
732
1112
|
// Both launcher shapes — the new state/<harness>/ one and the legacy one —
|
|
733
1113
|
// in one place; the recognisers below and doctor read it.
|
|
734
|
-
export const LAUNCHER_PATH_RE = new RegExp(
|
|
1114
|
+
export const LAUNCHER_PATH_RE = new RegExp(
|
|
1115
|
+
`${LAYOUT.root.replace(".", "\\.")}/(${LAYOUT.state}/[^/]+/)?${LAYOUT.launcher.replace(".", "\\.")}$`,
|
|
1116
|
+
);
|
|
735
1117
|
export function isLauncherPath(p) {
|
|
736
1118
|
return LAUNCHER_PATH_RE.test(String(p || "").replace(/\\/g, "/"));
|
|
737
1119
|
}
|
|
@@ -751,21 +1133,69 @@ export function statusLineScriptPath(cmd) {
|
|
|
751
1133
|
return m ? m[1].replace(/\\/g, "/") : null;
|
|
752
1134
|
}
|
|
753
1135
|
|
|
1136
|
+
// Is `p` a launcher path of ours in this project: any harness's launcher in
|
|
1137
|
+
// its state directory, or the legacy one? A path test — nothing is read. The
|
|
1138
|
+
// ownership check below and the SessionStart refresh both ask it.
|
|
1139
|
+
function ourLauncherPath(p, projectDir) {
|
|
1140
|
+
if (!p) return false;
|
|
1141
|
+
const norm = (s) => String(s).replace(/\\/g, "/");
|
|
1142
|
+
const q = norm(p);
|
|
1143
|
+
const lp = layoutPaths(projectDir);
|
|
1144
|
+
return (q.startsWith(norm(lp.state) + "/") && isLauncherPath(q)) || q === norm(lp.legacy.launcher);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// The provenance field of a registration directory's manifest, or null — the
|
|
1148
|
+
// install spec's contract 2 for a JSON directory: emitted by one function
|
|
1149
|
+
// (registrationManifest, the installer) and read by this one. The registration
|
|
1150
|
+
// analysis (surfaces.mjs) and statusLineIsOurWiring both call it; the key is
|
|
1151
|
+
// the surface's provenance_key.
|
|
1152
|
+
export function registrationProvenance(manifest, s) {
|
|
1153
|
+
const prov = manifest && s && s.provenance_key ? manifest[s.provenance_key] : null;
|
|
1154
|
+
return prov && typeof prov === "object" && typeof prov.pkg === "string" ? prov : null;
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// Is `p` the npm registration's own payload script —
|
|
1158
|
+
// <registration dir>/<plugin_subdir>/scripts/statusline.mjs, whose directory's
|
|
1159
|
+
// manifest carries our provenance field? The host loads that registration in
|
|
1160
|
+
// place from its marketplace directory, so a session refreshed the entry to
|
|
1161
|
+
// that script and a package root then read it as someone else's (the story
|
|
1162
|
+
// "The SessionStart hook and install agree on the status line of an npm
|
|
1163
|
+
// registration loaded from its marketplace directory"). A string test first;
|
|
1164
|
+
// the manifest is read only when the path has the payload's shape. Every name
|
|
1165
|
+
// comes from the source manifest's registration surface, and the directory
|
|
1166
|
+
// from `p` itself — never a path built from the home.
|
|
1167
|
+
function isRegistrationPayloadScript(p, harness = sourceHarness()) {
|
|
1168
|
+
const s = Object.values(harness?.surfaces || {}).find(
|
|
1169
|
+
(x) => x && x.kind === "registration" && x.format === "host-plugin-registration" && x.plugin_subdir && x.manifest,
|
|
1170
|
+
);
|
|
1171
|
+
if (!s) return false;
|
|
1172
|
+
const tail = `/${s.plugin_subdir}/scripts/statusline.mjs`;
|
|
1173
|
+
if (!p.endsWith(tail)) return false;
|
|
1174
|
+
try {
|
|
1175
|
+
return Boolean(
|
|
1176
|
+
registrationProvenance(JSON.parse(readFileSync(join(p.slice(0, -tail.length), s.manifest), "utf8")), s),
|
|
1177
|
+
);
|
|
1178
|
+
} catch {
|
|
1179
|
+
return false;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
|
|
754
1183
|
// Strict test — "did WE write this?". Required wherever we would overwrite or
|
|
755
1184
|
// delete the entry: the loose shape above also matches a user's own
|
|
756
1185
|
// ~/.claude/scripts/statusline.mjs, and clobbering that would take their HUD.
|
|
757
1186
|
// Ours means one of: this project's launcher, the running plugin's own script
|
|
758
|
-
// (dev checkouts included),
|
|
1187
|
+
// (dev checkouts included), any versioned install under the plugin cache, or
|
|
1188
|
+
// the npm registration's payload script — the one rung that reads a file, so
|
|
1189
|
+
// it runs last, after every cheaper test has failed.
|
|
759
1190
|
export function statusLineIsOurWiring(cmd, projectDir, home = homedir(), root = pluginRoot()) {
|
|
760
1191
|
const p = statusLineScriptPath(cmd);
|
|
761
1192
|
if (!p) return false;
|
|
762
1193
|
const norm = (s) => String(s).replace(/\\/g, "/");
|
|
763
1194
|
// Ours if it is any harness's launcher in this project's state, or the legacy launcher.
|
|
764
|
-
|
|
765
|
-
if (p.startsWith(norm(lp.state) + "/") && isLauncherPath(p)) return true;
|
|
766
|
-
if (p === norm(lp.legacy.launcher)) return true;
|
|
1195
|
+
if (ourLauncherPath(p, projectDir)) return true;
|
|
767
1196
|
if (p === norm(join(root, "scripts", "statusline.mjs"))) return true;
|
|
768
|
-
|
|
1197
|
+
if (isPluginCacheRoot(dirname(dirname(p)), home)) return true;
|
|
1198
|
+
return isRegistrationPayloadScript(p);
|
|
769
1199
|
}
|
|
770
1200
|
|
|
771
1201
|
// The launcher template runs standalone, before it knows which plugin root to
|
|
@@ -805,7 +1235,9 @@ export function writeStatusLineLauncher(projectDir, root) {
|
|
|
805
1235
|
if (src === null) return null;
|
|
806
1236
|
const p = statusLineLauncherPath(projectDir);
|
|
807
1237
|
let cur = null;
|
|
808
|
-
try {
|
|
1238
|
+
try {
|
|
1239
|
+
cur = readFileSync(p, "utf8");
|
|
1240
|
+
} catch {}
|
|
809
1241
|
if (cur !== src) {
|
|
810
1242
|
ensureStateDir(projectDir); // carries the nested .gitignore: this path is machine-specific
|
|
811
1243
|
mkdirSync(dirname(p), { recursive: true });
|
|
@@ -826,7 +1258,9 @@ export function desiredStatusLineCommand(projectDir, root = pluginRoot(), home =
|
|
|
826
1258
|
const launcher = isPluginCacheRoot(root, home);
|
|
827
1259
|
return {
|
|
828
1260
|
launcher,
|
|
829
|
-
command: launcher
|
|
1261
|
+
command: launcher
|
|
1262
|
+
? `node "${statusLineLauncherPath(projectDir)}"`
|
|
1263
|
+
: `node "${join(root, "scripts", "statusline.mjs")}"`,
|
|
830
1264
|
};
|
|
831
1265
|
}
|
|
832
1266
|
|
|
@@ -867,16 +1301,29 @@ export function syncStatusLine(cfg, projectDir, home = homedir()) {
|
|
|
867
1301
|
// nothing into the project, since we are not wiring anything here.
|
|
868
1302
|
if (cur && !isOurs) return "foreign-present";
|
|
869
1303
|
if (!cur) return "needs-install";
|
|
870
|
-
//
|
|
871
|
-
//
|
|
872
|
-
//
|
|
1304
|
+
// Install alone decides the entry's shape; this hook never demotes a
|
|
1305
|
+
// launcher. An entry naming a launcher of ours that is on disk is left
|
|
1306
|
+
// byte-identical, whatever root runs the hook: the host loads an npm
|
|
1307
|
+
// registration in place from its marketplace directory, which is not the
|
|
1308
|
+
// cache path the launcher was planned against, and re-pointing the entry
|
|
1309
|
+
// at that root's script undid every install (the install spec, contract 0
|
|
1310
|
+
// as amended 2026-10-10). Recognised by path and existsSync only — the
|
|
1311
|
+
// launcher is not read here.
|
|
1312
|
+
const named = statusLineScriptPath(curCmd);
|
|
1313
|
+
const namesLauncher = ourLauncherPath(named, projectDir);
|
|
1314
|
+
if (namesLauncher && existsSync(named)) return "unchanged";
|
|
1315
|
+
// Refresh: a launcher of ours on disk when this root is a cache install or
|
|
1316
|
+
// the entry named a launcher, else the plugin's own script, so an entry
|
|
1317
|
+
// always names a renderer that exists. A missing launcher is install's to
|
|
1318
|
+
// create (stamped), never this path's.
|
|
873
1319
|
const { launcher } = desiredStatusLineCommand(projectDir, root, home);
|
|
874
1320
|
// A launcher on disk at either path — the new one, or the legacy one an
|
|
875
1321
|
// earlier release wrote (the layout ADR) — is kept; moving it is install's.
|
|
876
|
-
const onDisk = [statusLineLauncherPath(projectDir), legacyStatusLineLauncherPath(projectDir)].find((f) =>
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1322
|
+
const onDisk = [statusLineLauncherPath(projectDir), legacyStatusLineLauncherPath(projectDir)].find((f) =>
|
|
1323
|
+
existsSync(f),
|
|
1324
|
+
);
|
|
1325
|
+
const desired =
|
|
1326
|
+
(launcher || namesLauncher) && onDisk ? `node "${onDisk}"` : `node "${join(root, "scripts", "statusline.mjs")}"`;
|
|
880
1327
|
if (curCmd !== desired) {
|
|
881
1328
|
// Keep any sibling keys the platform supports on this object
|
|
882
1329
|
// (refreshInterval and friends) — we own the command, not the entry.
|
|
@@ -953,19 +1400,40 @@ export function findAgentsBlock(text) {
|
|
|
953
1400
|
const loose = new RegExp(AGENTS_BLOCK_OPEN_LOOSE_SRC).exec(text);
|
|
954
1401
|
if (!loose) return null;
|
|
955
1402
|
const line = text.slice(0, loose.index).split("\n").length;
|
|
956
|
-
return {
|
|
1403
|
+
return {
|
|
1404
|
+
present: true,
|
|
1405
|
+
v: Number(loose[1]),
|
|
1406
|
+
start: loose.index,
|
|
1407
|
+
end: null,
|
|
1408
|
+
unclosed: true,
|
|
1409
|
+
wrapped: true,
|
|
1410
|
+
line,
|
|
1411
|
+
count,
|
|
1412
|
+
block: null,
|
|
1413
|
+
};
|
|
957
1414
|
}
|
|
958
1415
|
const closeAt = text.indexOf(AGENTS_BLOCK_CLOSE, m.index + m[0].length);
|
|
959
|
-
if (closeAt === -1)
|
|
1416
|
+
if (closeAt === -1)
|
|
1417
|
+
return { present: true, v: Number(m[1]), start: m.index, end: null, unclosed: true, count, block: null };
|
|
960
1418
|
const end = closeAt + AGENTS_BLOCK_CLOSE.length;
|
|
961
|
-
return {
|
|
1419
|
+
return {
|
|
1420
|
+
present: true,
|
|
1421
|
+
v: Number(m[1]),
|
|
1422
|
+
start: m.index,
|
|
1423
|
+
end,
|
|
1424
|
+
unclosed: false,
|
|
1425
|
+
count,
|
|
1426
|
+
block: text.slice(m.index, end),
|
|
1427
|
+
};
|
|
962
1428
|
}
|
|
963
1429
|
|
|
964
1430
|
// Whether `text` holds `line` as a line of its own, trimmed and exact — the
|
|
965
1431
|
// installer's match for the `@<file>` import, so `@./AGENTS.md` reads as
|
|
966
1432
|
// absent to install and doctor alike.
|
|
967
1433
|
export function importsLine(text, line) {
|
|
968
|
-
return String(text ?? "")
|
|
1434
|
+
return String(text ?? "")
|
|
1435
|
+
.split("\n")
|
|
1436
|
+
.some((l) => l.trim() === line);
|
|
969
1437
|
}
|
|
970
1438
|
|
|
971
1439
|
// Whether a harness sees the one agents block where it stands — the predicate
|
|
@@ -1025,14 +1493,24 @@ export function renderAgentsBlock(tmpl, roster = null) {
|
|
|
1025
1493
|
const AGENT_LINE = /^- [^:]*?:\s+(?:run|consult) (?:the )?`projectstore:([a-z]+)`/;
|
|
1026
1494
|
const flush = () => {
|
|
1027
1495
|
if (!bullet) return;
|
|
1028
|
-
const m = AGENT_LINE.exec(
|
|
1496
|
+
const m = AGENT_LINE.exec(
|
|
1497
|
+
bullet
|
|
1498
|
+
.map((l) => l.trim())
|
|
1499
|
+
.join(" ")
|
|
1500
|
+
.replace(/^- /, "- "),
|
|
1501
|
+
);
|
|
1029
1502
|
if (!names || !m || names.has(m[1])) out.push(...bullet);
|
|
1030
1503
|
bullet = null;
|
|
1031
1504
|
};
|
|
1032
1505
|
for (const l of lines) {
|
|
1033
|
-
if (/^- /.test(l)) {
|
|
1034
|
-
|
|
1035
|
-
|
|
1506
|
+
if (/^- /.test(l)) {
|
|
1507
|
+
flush();
|
|
1508
|
+
bullet = [l];
|
|
1509
|
+
} else if (bullet && /^\s+\S/.test(l)) bullet.push(l);
|
|
1510
|
+
else {
|
|
1511
|
+
flush();
|
|
1512
|
+
out.push(l);
|
|
1513
|
+
}
|
|
1036
1514
|
}
|
|
1037
1515
|
flush();
|
|
1038
1516
|
return out.join("\n");
|
|
@@ -1056,7 +1534,9 @@ export function layoutRoster(cfg, root = pluginRoot()) {
|
|
|
1056
1534
|
try {
|
|
1057
1535
|
const r = loadLayout(cfg.layout, root).agents;
|
|
1058
1536
|
return Array.isArray(r) && r.length ? r : null;
|
|
1059
|
-
} catch {
|
|
1537
|
+
} catch {
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1060
1540
|
}
|
|
1061
1541
|
|
|
1062
1542
|
export function folderByKind(layout, kind) {
|
|
@@ -1065,14 +1545,33 @@ export function folderByKind(layout, kind) {
|
|
|
1065
1545
|
|
|
1066
1546
|
// ─── Templates ─────────────────────────────────────────────────────────
|
|
1067
1547
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1548
|
+
// `root` is the plugin root to read from — the bin passes its own package's,
|
|
1549
|
+
// as it does for layouts, so a scaffold plans against the copy that runs.
|
|
1550
|
+
export function loadTemplate(lang, name, root = pluginRoot()) {
|
|
1551
|
+
const p = join(root, "templates", lang, `${name}.md.tmpl`);
|
|
1070
1552
|
if (!existsSync(p)) {
|
|
1071
1553
|
throw new Error(`Template not found: templates/${lang}/${name}.md.tmpl`);
|
|
1072
1554
|
}
|
|
1073
1555
|
return readFileSync(p, "utf8");
|
|
1074
1556
|
}
|
|
1075
1557
|
|
|
1558
|
+
// templates/<lang>/strings.json, parsed — or null when the language has none
|
|
1559
|
+
// or it does not parse. No English fallback, unlike the status line's own
|
|
1560
|
+
// reader: the folder names and descriptions are written into a vault's
|
|
1561
|
+
// READMEs, and an English description in a Russian vault would read as the
|
|
1562
|
+
// vault's own prose forever after (the skeleton's Purpose column takes it
|
|
1563
|
+
// from there). A missing string is the caller's refusal to name.
|
|
1564
|
+
export function loadStrings(lang, root = pluginRoot()) {
|
|
1565
|
+
const p = join(root, "templates", String(lang), "strings.json");
|
|
1566
|
+
if (!existsSync(p)) return null;
|
|
1567
|
+
try {
|
|
1568
|
+
const s = JSON.parse(readFileSync(p, "utf8"));
|
|
1569
|
+
return s && typeof s === "object" && !Array.isArray(s) ? s : null;
|
|
1570
|
+
} catch {
|
|
1571
|
+
return null;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1076
1575
|
// {{x}} substitutes raw; {{x_json}} substitutes JSON.stringify(String(x)) — a
|
|
1077
1576
|
// valid YAML double-quoted scalar. Frontmatter lines in templates use the
|
|
1078
1577
|
// _json form so titles containing `"` or `:` cannot corrupt the YAML.
|
|
@@ -1100,7 +1599,9 @@ export function renderTemplate(template, vars) {
|
|
|
1100
1599
|
// language is the canonical form used when WRITING. Matching always accepts
|
|
1101
1600
|
// every registered form of every language — a ru-headed file in an en-bound
|
|
1102
1601
|
// vault must still lint. This is deliberately separate from
|
|
1103
|
-
// templates/<lang>/strings.json,
|
|
1602
|
+
// templates/<lang>/strings.json, a render-only map: the status line's strings
|
|
1603
|
+
// and the folder names and descriptions scaffold writes into READMEs, which
|
|
1604
|
+
// nothing ever matches against.
|
|
1104
1605
|
|
|
1105
1606
|
let _headingsCache = null;
|
|
1106
1607
|
|
|
@@ -1110,7 +1611,7 @@ export function loadHeadingsRegistry() {
|
|
|
1110
1611
|
try {
|
|
1111
1612
|
_headingsCache = JSON.parse(readFileSync(p, "utf8"));
|
|
1112
1613
|
} catch (e) {
|
|
1113
|
-
throw new Error(`heading registry missing or unreadable (${p}): ${e.message}
|
|
1614
|
+
throw new Error(`heading registry missing or unreadable (${p}): ${e.message}`, { cause: e });
|
|
1114
1615
|
}
|
|
1115
1616
|
return _headingsCache;
|
|
1116
1617
|
}
|
|
@@ -1192,10 +1693,10 @@ export function footerDateRe() {
|
|
|
1192
1693
|
// so the loss had no detector at all. Anchored, both halves behave as
|
|
1193
1694
|
// documented: reconcile reports the index unusable and doctor warns.
|
|
1194
1695
|
export function indexHeaderRe() {
|
|
1195
|
-
const cols = ["file", "title", "status", "date"].map((c) =>
|
|
1196
|
-
allForms("index_columns", c).map(escapeRe).join("|"));
|
|
1696
|
+
const cols = ["file", "title", "status", "date"].map((c) => allForms("index_columns", c).map(escapeRe).join("|"));
|
|
1197
1697
|
return new RegExp(
|
|
1198
|
-
`^\\|\\s*(?:${cols[0]})\\s*\\|\\s*(?:${cols[1]})\\s*\\|\\s*(?:${cols[2]})\\s*\\|\\s*(?:${cols[3]})\\s*\\|\\s
|
|
1698
|
+
`^\\|\\s*(?:${cols[0]})\\s*\\|\\s*(?:${cols[1]})\\s*\\|\\s*(?:${cols[2]})\\s*\\|\\s*(?:${cols[3]})\\s*\\|\\s*$`,
|
|
1699
|
+
);
|
|
1199
1700
|
}
|
|
1200
1701
|
|
|
1201
1702
|
// ─── Frontmatter list fields (code_refs / specs / stories / adr) ───────
|
|
@@ -1261,10 +1762,39 @@ export function isLegacyStory(fm, since) {
|
|
|
1261
1762
|
// Cyrillic → Latin so ru titles produce portable ASCII filenames; every other
|
|
1262
1763
|
// Unicode letter/digit survives via \p{L}\p{N}. Never returns an empty slug.
|
|
1263
1764
|
const CYRILLIC = {
|
|
1264
|
-
а: "a",
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1765
|
+
а: "a",
|
|
1766
|
+
б: "b",
|
|
1767
|
+
в: "v",
|
|
1768
|
+
г: "g",
|
|
1769
|
+
д: "d",
|
|
1770
|
+
е: "e",
|
|
1771
|
+
ё: "e",
|
|
1772
|
+
ж: "zh",
|
|
1773
|
+
з: "z",
|
|
1774
|
+
и: "i",
|
|
1775
|
+
й: "y",
|
|
1776
|
+
к: "k",
|
|
1777
|
+
л: "l",
|
|
1778
|
+
м: "m",
|
|
1779
|
+
н: "n",
|
|
1780
|
+
о: "o",
|
|
1781
|
+
п: "p",
|
|
1782
|
+
р: "r",
|
|
1783
|
+
с: "s",
|
|
1784
|
+
т: "t",
|
|
1785
|
+
у: "u",
|
|
1786
|
+
ф: "f",
|
|
1787
|
+
х: "h",
|
|
1788
|
+
ц: "ts",
|
|
1789
|
+
ч: "ch",
|
|
1790
|
+
ш: "sh",
|
|
1791
|
+
щ: "shch",
|
|
1792
|
+
ъ: "",
|
|
1793
|
+
ы: "y",
|
|
1794
|
+
ь: "",
|
|
1795
|
+
э: "e",
|
|
1796
|
+
ю: "yu",
|
|
1797
|
+
я: "ya",
|
|
1268
1798
|
};
|
|
1269
1799
|
|
|
1270
1800
|
export function slugify(s) {
|
|
@@ -1460,7 +1990,11 @@ export function listStoryFiles(storiesDir) {
|
|
|
1460
1990
|
for (const entry of readdirSync(storiesDir).sort()) {
|
|
1461
1991
|
const full = join(storiesDir, entry);
|
|
1462
1992
|
let st;
|
|
1463
|
-
try {
|
|
1993
|
+
try {
|
|
1994
|
+
st = statSync(full);
|
|
1995
|
+
} catch {
|
|
1996
|
+
continue;
|
|
1997
|
+
}
|
|
1464
1998
|
if (st.isFile() && entry.endsWith(".md")) {
|
|
1465
1999
|
out.push({ abs: full, rel: entry, slug: entry.replace(/\.md$/, "") });
|
|
1466
2000
|
continue;
|
|
@@ -1486,14 +2020,22 @@ export function listStoryFiles(storiesDir) {
|
|
|
1486
2020
|
export function listEpicStories(epicDir) {
|
|
1487
2021
|
const out = [];
|
|
1488
2022
|
// epics/ holds loose files too (README, notes) — only folders are epics.
|
|
1489
|
-
try {
|
|
2023
|
+
try {
|
|
2024
|
+
if (!statSync(epicDir).isDirectory()) return out;
|
|
2025
|
+
} catch {
|
|
2026
|
+
return out;
|
|
2027
|
+
}
|
|
1490
2028
|
for (const s of listStoryFiles(join(epicDir, "stories"))) {
|
|
1491
2029
|
out.push({ abs: s.abs, rel: `stories/${s.rel}`, slug: s.slug });
|
|
1492
2030
|
}
|
|
1493
2031
|
for (const entry of readdirSync(epicDir).sort()) {
|
|
1494
2032
|
if (!entry.startsWith("story-") || !entry.endsWith(".md")) continue;
|
|
1495
2033
|
const full = join(epicDir, entry);
|
|
1496
|
-
try {
|
|
2034
|
+
try {
|
|
2035
|
+
if (!statSync(full).isFile()) continue;
|
|
2036
|
+
} catch {
|
|
2037
|
+
continue;
|
|
2038
|
+
}
|
|
1497
2039
|
out.push({ abs: full, rel: entry, slug: entry.replace(/\.md$/, "") });
|
|
1498
2040
|
}
|
|
1499
2041
|
return out;
|
|
@@ -1574,14 +2116,16 @@ export function buildNodeIndex(cfg, layout) {
|
|
|
1574
2116
|
const nodes = [];
|
|
1575
2117
|
const push = (abs, rel, type, { prefix = null, story = false } = {}) => {
|
|
1576
2118
|
let md;
|
|
1577
|
-
try {
|
|
2119
|
+
try {
|
|
2120
|
+
md = readFileSync(abs, "utf8");
|
|
2121
|
+
} catch {
|
|
2122
|
+
return;
|
|
2123
|
+
}
|
|
1578
2124
|
const fm = parseFrontmatter(md).data;
|
|
1579
2125
|
const name = basename(rel);
|
|
1580
2126
|
// A folder-shape story is identified by its folder name, like doctor's
|
|
1581
2127
|
// storyStemOf; every other node by its filename stem.
|
|
1582
|
-
const stem = name === "README.md"
|
|
1583
|
-
? basename(dirname(rel))
|
|
1584
|
-
: name.replace(/\.md$/i, "");
|
|
2128
|
+
const stem = name === "README.md" ? basename(dirname(rel)) : name.replace(/\.md$/i, "");
|
|
1585
2129
|
const idField = fm.id ?? fm.slug; // research/concept/runbook/meeting templates carry slug:, not id:
|
|
1586
2130
|
nodes.push({
|
|
1587
2131
|
path: rel,
|
|
@@ -1706,10 +2250,13 @@ export function resolveLinkTarget(rawTarget, linkType, ctx) {
|
|
|
1706
2250
|
const tiers = [
|
|
1707
2251
|
() => (index.byIdentity.get(key) || []).filter(eligible),
|
|
1708
2252
|
() => (index.byStem.get(key) || []).filter(eligible),
|
|
1709
|
-
() =>
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
2253
|
+
() =>
|
|
2254
|
+
index.nodes.filter(
|
|
2255
|
+
(n) =>
|
|
2256
|
+
eligible(n) &&
|
|
2257
|
+
isLegacyNumberedId(target, n.story ? { story: true } : { prefix: n.prefix }) &&
|
|
2258
|
+
n.stem.toLowerCase().startsWith(`${key}-`),
|
|
2259
|
+
),
|
|
1713
2260
|
];
|
|
1714
2261
|
for (const tier of tiers) {
|
|
1715
2262
|
const hits = [...new Set(tier())];
|
|
@@ -1734,11 +2281,11 @@ export function resolveLinkTarget(rawTarget, linkType, ctx) {
|
|
|
1734
2281
|
// split is what makes the bounds unit-testable without a slow filesystem, and
|
|
1735
2282
|
// what stops a second convenience being paid for out of the same budget later.
|
|
1736
2283
|
|
|
1737
|
-
export const PURPOSE_CELL = 160;
|
|
1738
|
-
export const TITLE_CELL = 80;
|
|
1739
|
-
export const PATH_CELL = 200;
|
|
1740
|
-
export const INFLIGHT_CAP = 5;
|
|
1741
|
-
export const ERROR_CELL = 500;
|
|
2284
|
+
export const PURPOSE_CELL = 160; // contract 1
|
|
2285
|
+
export const TITLE_CELL = 80; // contract 1
|
|
2286
|
+
export const PATH_CELL = 200; // contracts 1, 19 — measured: longest path here is 123
|
|
2287
|
+
export const INFLIGHT_CAP = 5; // contracts 1, 19
|
|
2288
|
+
export const ERROR_CELL = 500; // contract 3
|
|
1742
2289
|
|
|
1743
2290
|
// A truncation marks itself, and the mark counts toward the budget (contract 1).
|
|
1744
2291
|
export function truncEnd(s, max) {
|
|
@@ -2077,8 +2624,10 @@ export function renderVaultSkeleton(facts) {
|
|
|
2077
2624
|
if (more > 0) L.push(`- …and ${more} more; see \`${commandForm("status")}\``);
|
|
2078
2625
|
if (cont.artifact) {
|
|
2079
2626
|
L.push("");
|
|
2080
|
-
L.push(
|
|
2081
|
-
|
|
2627
|
+
L.push(
|
|
2628
|
+
`**In flight**: ${pathCell(cont.artifact)} was the newest structured write before` +
|
|
2629
|
+
" compaction. If we were drafting it, continue from there.",
|
|
2630
|
+
);
|
|
2082
2631
|
}
|
|
2083
2632
|
L.push("");
|
|
2084
2633
|
}
|
|
@@ -2142,7 +2691,11 @@ export function ensureSessionsDir(vault) {
|
|
|
2142
2691
|
// vault lives. The .gitignore inside <vault>/.projectstore/ is merged by
|
|
2143
2692
|
// line (2026-09-06: a vault that is also a project shares the directory
|
|
2144
2693
|
// with the project layout, whose committed harness/ a "*" would hide).
|
|
2145
|
-
ensureGitignoreLines(
|
|
2694
|
+
ensureGitignoreLines(
|
|
2695
|
+
join(vault, LAYOUT.root, ".gitignore"),
|
|
2696
|
+
[`${LAYOUT.vaultSessions}/`],
|
|
2697
|
+
"projectstore — runtime data, do not commit",
|
|
2698
|
+
);
|
|
2146
2699
|
return dir;
|
|
2147
2700
|
}
|
|
2148
2701
|
|
|
@@ -2154,7 +2707,9 @@ export function writeSession(vault, sessionId, projectRoot) {
|
|
|
2154
2707
|
const path = sessionFilePath(vault, sessionId);
|
|
2155
2708
|
let existing = null;
|
|
2156
2709
|
if (existsSync(path)) {
|
|
2157
|
-
try {
|
|
2710
|
+
try {
|
|
2711
|
+
existing = JSON.parse(readFileSync(path, "utf8"));
|
|
2712
|
+
} catch {}
|
|
2158
2713
|
}
|
|
2159
2714
|
const data = {
|
|
2160
2715
|
id: sessionId,
|
|
@@ -2189,10 +2744,18 @@ export function readActiveSessions(vault, currentSessionId, maxAgeMinutes = 30)
|
|
|
2189
2744
|
if (!name.endsWith(".json")) continue;
|
|
2190
2745
|
const path = join(dir, name);
|
|
2191
2746
|
let stat;
|
|
2192
|
-
try {
|
|
2747
|
+
try {
|
|
2748
|
+
stat = statSync(path);
|
|
2749
|
+
} catch {
|
|
2750
|
+
continue;
|
|
2751
|
+
}
|
|
2193
2752
|
if (stat.mtimeMs < cutoff) continue;
|
|
2194
2753
|
let data;
|
|
2195
|
-
try {
|
|
2754
|
+
try {
|
|
2755
|
+
data = JSON.parse(readFileSync(path, "utf8"));
|
|
2756
|
+
} catch {
|
|
2757
|
+
continue;
|
|
2758
|
+
}
|
|
2196
2759
|
if (data.id === currentSessionId) continue;
|
|
2197
2760
|
out.push({ ...data, last_active: stat.mtime });
|
|
2198
2761
|
}
|
|
@@ -2236,7 +2799,6 @@ export function cleanupStaleSessions(vault, maxAgeHours = 24, currentSessionId =
|
|
|
2236
2799
|
// behind by v0.3 – v0.5 (file-based per-project session id). Safe to call
|
|
2237
2800
|
// on every session start; no-op if the file is absent. Kept until v0.7.
|
|
2238
2801
|
|
|
2239
|
-
|
|
2240
2802
|
// ─── Session activity log ──────────────────────────────────────────────
|
|
2241
2803
|
//
|
|
2242
2804
|
// Each session file may carry a `recent_activity` array, populated by
|
|
@@ -2348,7 +2910,7 @@ export function isInsideVault(filePath, vaultPath) {
|
|
|
2348
2910
|
// so per-session ids/titles never reach the user's git history.
|
|
2349
2911
|
|
|
2350
2912
|
// The WRITER's view: <project>/.projectstore/state/sessions. Readers fall back
|
|
2351
|
-
// to the legacy .claude/.projectstore/state through 0.
|
|
2913
|
+
// to the legacy .claude/.projectstore/state through 0.30 (readSessionState).
|
|
2352
2914
|
export function stateDir(projectDir) {
|
|
2353
2915
|
return layoutPaths(projectDir).sessions;
|
|
2354
2916
|
}
|
|
@@ -2365,7 +2927,11 @@ export function sessionStatePath(projectDir, sessionId) {
|
|
|
2365
2927
|
export function ensureRuntimeDir(projectDir) {
|
|
2366
2928
|
const p = layoutPaths(projectDir);
|
|
2367
2929
|
mkdirSync(p.root, { recursive: true });
|
|
2368
|
-
ensureGitignoreLines(
|
|
2930
|
+
ensureGitignoreLines(
|
|
2931
|
+
p.gitignore,
|
|
2932
|
+
[...LAYOUT.gitignore],
|
|
2933
|
+
"projectstore — the binding and the state are machine-local; harness/ is committed",
|
|
2934
|
+
);
|
|
2369
2935
|
return p.root;
|
|
2370
2936
|
}
|
|
2371
2937
|
|
|
@@ -2373,16 +2939,32 @@ export function ensureRuntimeDir(projectDir) {
|
|
|
2373
2939
|
// harness inside; its own ignore file so nothing in here can reach git,
|
|
2374
2940
|
// whichever writer gets there first.
|
|
2375
2941
|
export function ensureStateDir(projectDir) {
|
|
2376
|
-
|
|
2942
|
+
ensureStateRoot(projectDir);
|
|
2377
2943
|
const p = layoutPaths(projectDir);
|
|
2378
2944
|
mkdirSync(p.sessions, { recursive: true });
|
|
2379
|
-
if (!existsSync(p.stateGitignore)) writeFileSync(p.stateGitignore, `# ${RUNTIME_GITIGNORE_HEADER}, do not commit\n*\n`, "utf8");
|
|
2380
2945
|
return p.sessions;
|
|
2381
2946
|
}
|
|
2382
2947
|
|
|
2948
|
+
// state/ and its .gitignore, header included, without sessions/: what the
|
|
2949
|
+
// welcome marker's writer needs in a project that is not bound, where no
|
|
2950
|
+
// session state may appear. The header is how a project uninstall
|
|
2951
|
+
// recognises the marker as ours (install spec contract 13, as amended by the
|
|
2952
|
+
// shell-fetch story).
|
|
2953
|
+
export function ensureStateRoot(projectDir) {
|
|
2954
|
+
ensureRuntimeDir(projectDir);
|
|
2955
|
+
const p = layoutPaths(projectDir);
|
|
2956
|
+
mkdirSync(p.state, { recursive: true });
|
|
2957
|
+
if (!existsSync(p.stateGitignore))
|
|
2958
|
+
writeFileSync(p.stateGitignore, `# ${RUNTIME_GITIGNORE_HEADER}, do not commit\n*\n`, "utf8");
|
|
2959
|
+
return p.state;
|
|
2960
|
+
}
|
|
2961
|
+
|
|
2383
2962
|
export function readSessionState(projectDir, sessionId) {
|
|
2384
2963
|
try {
|
|
2385
|
-
const p = pickExisting(
|
|
2964
|
+
const p = pickExisting(
|
|
2965
|
+
sessionStatePath(projectDir, sessionId),
|
|
2966
|
+
join(legacyStateDir(projectDir), `${sessionId}.json`),
|
|
2967
|
+
);
|
|
2386
2968
|
return JSON.parse(readFileSync(p, "utf8"));
|
|
2387
2969
|
} catch {
|
|
2388
2970
|
return null;
|
|
@@ -2401,13 +2983,137 @@ export function writeSessionState(projectDir, sessionId, patch) {
|
|
|
2401
2983
|
// readSessionState answers null, so the next write keeps only its own patch
|
|
2402
2984
|
// and the rest of the pointer is gone for good. The rename also hides the
|
|
2403
2985
|
// empty instant after the truncate. The
|
|
2404
|
-
// default sweep stays on: this writer runs only on vault-file tool calls,
|
|
2986
|
+
// default sweep stays on: this writer runs only on vault-file tool calls,
|
|
2987
|
+
// story-close refreshes and once at SessionStart, so
|
|
2405
2988
|
// it can afford the readdir that reaps orphan temps here (the status line's
|
|
2406
2989
|
// breadcrumb, written beside it, opts out because it renders every refresh).
|
|
2407
2990
|
writeFileAtomic(sessionStatePath(projectDir, sessionId), JSON.stringify(next, null, 2));
|
|
2408
2991
|
return next;
|
|
2409
2992
|
}
|
|
2410
2993
|
|
|
2994
|
+
// ─── The pointer's epic and story (ADR-006 decision 1, amended 2026-10-12) ──
|
|
2995
|
+
// What touching one vault file says about this session's work: the epic it
|
|
2996
|
+
// sits under and, for a story, the story's key, title and status as the file
|
|
2997
|
+
// says them NOW — callers run after a write, never before it. The merge in
|
|
2998
|
+
// writeSessionState cannot delete a key, so a story field with no value is
|
|
2999
|
+
// written as null (every reader treats it as absent): a touch of another
|
|
3000
|
+
// epic clears the previous epic's story, and an unreadable story names no
|
|
3001
|
+
// stale title. Null when the file is not under an epic.
|
|
3002
|
+
export function pointerPatchFor(vaultPath, filePath, cur, read = (p) => readFileSync(p, "utf8")) {
|
|
3003
|
+
const m = filePath.slice(vaultPath.length + 1).match(/^epics\/([^/]+)\/(.+)$/);
|
|
3004
|
+
if (!m) return null;
|
|
3005
|
+
const [, epicId, inEpic] = m;
|
|
3006
|
+
const patch = { active_epic: epicId };
|
|
3007
|
+
try {
|
|
3008
|
+
const { data } = parseFrontmatter(read(join(vaultPath, "epics", epicId, "epic.md")));
|
|
3009
|
+
if (data.title) patch.epic_title = String(data.title);
|
|
3010
|
+
} catch {}
|
|
3011
|
+
const key = storyKeyOf(inEpic);
|
|
3012
|
+
if (key) {
|
|
3013
|
+
let data = null;
|
|
3014
|
+
try {
|
|
3015
|
+
data = parseFrontmatter(read(filePath)).data;
|
|
3016
|
+
} catch {}
|
|
3017
|
+
patch.active_story = key;
|
|
3018
|
+
patch.story_title = data && data.title ? String(data.title) : null;
|
|
3019
|
+
patch.story_status = data && data.status ? String(data.status) : null;
|
|
3020
|
+
} else if (!cur || cur.active_epic !== epicId) {
|
|
3021
|
+
Object.assign(patch, { active_story: null, story_title: null, story_status: null });
|
|
3022
|
+
}
|
|
3023
|
+
return patch;
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
// The file a pointer's story key names, by the three shapes the board lists
|
|
3027
|
+
// (storyKeyOf); null when none exists. existsSync is a stat, which does not
|
|
3028
|
+
// download an evicted iCloud file.
|
|
3029
|
+
export function storyFileFor(vaultPath, epicId, key) {
|
|
3030
|
+
if (!epicId || !key || /[/\\\0]/.test(epicId) || /[/\\\0]/.test(key)) return null;
|
|
3031
|
+
const epicDir = join(vaultPath, "epics", epicId);
|
|
3032
|
+
const candidates = [join(epicDir, "stories", `${key}.md`), join(epicDir, "stories", key, "README.md")];
|
|
3033
|
+
if (key.startsWith("story-")) candidates.push(join(epicDir, `${key}.md`));
|
|
3034
|
+
return candidates.find((p) => existsSync(p)) || null;
|
|
3035
|
+
}
|
|
3036
|
+
|
|
3037
|
+
// `projectstore session refresh <path>` — ADR-006 decision 1's "projectstore
|
|
3038
|
+
// commands on approved epic/story writes". The close ceremony copies the
|
|
3039
|
+
// story through Bash, which no hook sees, so the story command calls this
|
|
3040
|
+
// after the copy: this session's pointer is patched from the file as it is
|
|
3041
|
+
// now, as a touch of that file would be. The session is the host's
|
|
3042
|
+
// (session_id_env); a harness that names none gets nothing written and no
|
|
3043
|
+
// failure, so a close there is never broken by it. `path` is vault-relative
|
|
3044
|
+
// or absolute inside the vault.
|
|
3045
|
+
export function refreshSessionPointer(projectDir, cfg, path, { env = process.env } = {}) {
|
|
3046
|
+
const vault = cfg.vault_path;
|
|
3047
|
+
const abs = isAbsolute(path) ? resolve(path) : join(vault, path);
|
|
3048
|
+
const rel = abs.startsWith(`${vault}/`) ? abs.slice(vault.length + 1) : null;
|
|
3049
|
+
if (!rel) return { refusal: { code: "NOT_IN_VAULT", message: `${path} is not inside the bound vault (${vault})` } };
|
|
3050
|
+
if (!existsSync(abs)) return { refusal: { code: "NOT_FOUND", message: `${rel} does not exist in the vault` } };
|
|
3051
|
+
const sessionId = hostSessionId(env);
|
|
3052
|
+
const cur = sessionId ? readSessionState(projectDir, sessionId) : null;
|
|
3053
|
+
const patch = pointerPatchFor(vault, abs, cur);
|
|
3054
|
+
if (!patch || !patch.active_story)
|
|
3055
|
+
return { refusal: { code: "NOT_A_STORY", message: `${rel} is not a story file under an epic` } };
|
|
3056
|
+
if (!sessionId) return { session: null, refreshed: false, reason: "no-session", path: rel };
|
|
3057
|
+
const next = writeSessionState(projectDir, sessionId, patch);
|
|
3058
|
+
return {
|
|
3059
|
+
session: sessionId,
|
|
3060
|
+
refreshed: true,
|
|
3061
|
+
path: rel,
|
|
3062
|
+
pointer: {
|
|
3063
|
+
active_epic: next.active_epic ?? null,
|
|
3064
|
+
active_story: next.active_story ?? null,
|
|
3065
|
+
story_title: next.story_title ?? null,
|
|
3066
|
+
story_status: next.story_status ?? null,
|
|
3067
|
+
},
|
|
3068
|
+
};
|
|
3069
|
+
}
|
|
3070
|
+
|
|
3071
|
+
// SessionStart's refresh of this session's story fields from the story file:
|
|
3072
|
+
// a pointer written before a close (0.29.3 patched it before the write, and
|
|
3073
|
+
// never on the close ceremony's copy), or a story closed in another session
|
|
3074
|
+
// or by hand since. The read races a budget, as resolveOpenStory's does — an
|
|
3075
|
+
// evicted file blocks — and a lost race writes nothing. A story the epic no
|
|
3076
|
+
// longer has clears the fields; a vault or epic folder that is not there (an
|
|
3077
|
+
// unmounted drive) proves nothing and writes nothing.
|
|
3078
|
+
// "refreshed" | "unchanged" | "unknown".
|
|
3079
|
+
export async function refreshStoryPointer(projectDir, vaultPath, sessionId, opts = {}) {
|
|
3080
|
+
const cur = readSessionState(projectDir, sessionId);
|
|
3081
|
+
if (!cur || !cur.active_epic || !cur.active_story) return "unchanged";
|
|
3082
|
+
const file = storyFileFor(vaultPath, cur.active_epic, cur.active_story);
|
|
3083
|
+
if (!file && !existsSync(join(vaultPath, "epics", cur.active_epic, "epic.md"))) return "unknown";
|
|
3084
|
+
let title = null;
|
|
3085
|
+
let status = null;
|
|
3086
|
+
if (file) {
|
|
3087
|
+
const readFile = opts.readFile || ((p) => readFileAsync(p, "utf8"));
|
|
3088
|
+
let timer = null;
|
|
3089
|
+
const deadline = new Promise((resolve) => {
|
|
3090
|
+
timer = setTimeout(() => resolve(null), opts.budgetMs ?? 200);
|
|
3091
|
+
});
|
|
3092
|
+
let text;
|
|
3093
|
+
try {
|
|
3094
|
+
text = await Promise.race([
|
|
3095
|
+
Promise.resolve()
|
|
3096
|
+
.then(() => readFile(file))
|
|
3097
|
+
.then(String, () => null),
|
|
3098
|
+
deadline,
|
|
3099
|
+
]);
|
|
3100
|
+
} finally {
|
|
3101
|
+
if (timer) clearTimeout(timer);
|
|
3102
|
+
}
|
|
3103
|
+
if (text === null) return "unknown";
|
|
3104
|
+
const { data } = parseFrontmatter(text);
|
|
3105
|
+
title = data.title ? String(data.title) : null;
|
|
3106
|
+
status = data.status ? String(data.status) : null;
|
|
3107
|
+
}
|
|
3108
|
+
const patch = file
|
|
3109
|
+
? { story_title: title, story_status: status }
|
|
3110
|
+
: { active_story: null, story_title: null, story_status: null };
|
|
3111
|
+
const same = Object.entries(patch).every(([k, v]) => (cur[k] ?? null) === v);
|
|
3112
|
+
if (same) return "unchanged";
|
|
3113
|
+
writeSessionState(projectDir, sessionId, patch);
|
|
3114
|
+
return "refreshed";
|
|
3115
|
+
}
|
|
3116
|
+
|
|
2411
3117
|
export function cleanupStaleSessionState(projectDir, maxAgeHours = 24) {
|
|
2412
3118
|
const dir = stateDir(projectDir);
|
|
2413
3119
|
if (!existsSync(dir)) return 0;
|
|
@@ -2455,23 +3161,36 @@ export function cleanupStaleSessionState(projectDir, maxAgeHours = 24) {
|
|
|
2455
3161
|
// consumer. Lifted out of diff-refs.mjs so the hook, doctor and diff-refs
|
|
2456
3162
|
// cannot drift apart (contract 1).
|
|
2457
3163
|
export const SOURCE_IGNORE = [
|
|
2458
|
-
/(^|\/)package-lock\.json$/,
|
|
2459
|
-
/(^|\/)
|
|
2460
|
-
/(^|\/)\.
|
|
3164
|
+
/(^|\/)package-lock\.json$/,
|
|
3165
|
+
/(^|\/)yarn\.lock$/,
|
|
3166
|
+
/(^|\/)pnpm-lock\.yaml$/,
|
|
3167
|
+
/(^|\/)Cargo\.lock$/,
|
|
3168
|
+
/(^|\/)node_modules\//,
|
|
3169
|
+
/(^|\/)dist\//,
|
|
3170
|
+
/(^|\/)build\//,
|
|
3171
|
+
/(^|\/)\.claude\//,
|
|
3172
|
+
/\.min\.(js|css)$/,
|
|
2461
3173
|
];
|
|
2462
3174
|
|
|
2463
3175
|
// The entry-rule counter ignores strictly more than the base set, and the
|
|
2464
3176
|
// difference is deliberate rather than an oversight of one or the other.
|
|
2465
|
-
// /projectstore:bind writes these
|
|
3177
|
+
// /projectstore:bind writes these itself, in a session that by
|
|
2466
3178
|
// construction has no story open — counting them makes the plugin nag about its
|
|
2467
3179
|
// own setup. They must NOT join SOURCE_IGNORE: an edit to AGENTS.md is a real
|
|
2468
3180
|
// code reference (the PS-AGENTS epic already lists it), and folding these into
|
|
2469
3181
|
// the shared set would silently drop it from every proposed code_refs.
|
|
2470
3182
|
// Root-anchored, unlike the patterns above: a monorepo's nested AGENTS.md is
|
|
2471
|
-
// ordinary source and must still count.
|
|
3183
|
+
// ordinary source and must still count. The fourth is the binding's own
|
|
3184
|
+
// ignore file, which `bind` and `init` write through ensureRuntimeDir: the
|
|
3185
|
+
// layout move added it and this list missed it, so a fresh `init` on a clean
|
|
3186
|
+
// project read as one uncommitted source file (measured 2026-10-08 on 0.29.2).
|
|
3187
|
+
// Built from LAYOUT.root, which the resolver alone spells (layout contract 0).
|
|
2472
3188
|
export const ENTRY_IGNORE = [
|
|
2473
3189
|
...SOURCE_IGNORE,
|
|
2474
|
-
/^AGENTS\.md$/,
|
|
3190
|
+
/^AGENTS\.md$/,
|
|
3191
|
+
/^CLAUDE\.md$/,
|
|
3192
|
+
/^\.gitignore$/,
|
|
3193
|
+
new RegExp(`^${LAYOUT.root.replace(".", "\\.")}\\/\\.gitignore$`),
|
|
2475
3194
|
];
|
|
2476
3195
|
|
|
2477
3196
|
export function isSourcePath(absPath, projectDir, vaultPath) {
|
|
@@ -2545,7 +3264,11 @@ export function listVaultStoryFiles(vaultPath) {
|
|
|
2545
3264
|
const out = [];
|
|
2546
3265
|
const epicsDir = join(vaultPath, "epics");
|
|
2547
3266
|
let entries;
|
|
2548
|
-
try {
|
|
3267
|
+
try {
|
|
3268
|
+
entries = readdirSync(epicsDir).sort();
|
|
3269
|
+
} catch {
|
|
3270
|
+
return out;
|
|
3271
|
+
}
|
|
2549
3272
|
for (const e of entries) {
|
|
2550
3273
|
for (const s of listEpicStories(join(epicsDir, e))) out.push(s.abs);
|
|
2551
3274
|
}
|
|
@@ -2575,7 +3298,11 @@ export async function resolveOpenStory(vaultPath, opts = {}) {
|
|
|
2575
3298
|
const scan = (async () => {
|
|
2576
3299
|
for (const f of files) {
|
|
2577
3300
|
let text;
|
|
2578
|
-
try {
|
|
3301
|
+
try {
|
|
3302
|
+
text = await readFile(f);
|
|
3303
|
+
} catch {
|
|
3304
|
+
continue;
|
|
3305
|
+
}
|
|
2579
3306
|
// String(): the injected reader is a seam, and a caller that forgets an
|
|
2580
3307
|
// encoding hands back a Buffer. Coercing here keeps that a non-event.
|
|
2581
3308
|
if (openStoryFrom([parseFrontmatter(String(text)).data])) return true;
|
|
@@ -2598,7 +3325,11 @@ export function lastVaultActivityMs(vaultPath) {
|
|
|
2598
3325
|
let newest = 0;
|
|
2599
3326
|
const walk = (dir) => {
|
|
2600
3327
|
let entries;
|
|
2601
|
-
try {
|
|
3328
|
+
try {
|
|
3329
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
3330
|
+
} catch {
|
|
3331
|
+
return;
|
|
3332
|
+
}
|
|
2602
3333
|
for (const e of entries) {
|
|
2603
3334
|
if (e.name.startsWith(".")) continue; // .projectstore/, .obsidian/, .git/
|
|
2604
3335
|
const p = join(dir, e.name);
|
|
@@ -2657,8 +3388,7 @@ export function writeOpenStoryCache(projectDir, sessionId, value) {
|
|
|
2657
3388
|
|
|
2658
3389
|
export function firedCount(projectDir, sessionId) {
|
|
2659
3390
|
try {
|
|
2660
|
-
return readdirSync(markerDir(projectDir, sessionId))
|
|
2661
|
-
.filter((n) => /^fired-\d+$/.test(n)).length;
|
|
3391
|
+
return readdirSync(markerDir(projectDir, sessionId)).filter((n) => /^fired-\d+$/.test(n)).length;
|
|
2662
3392
|
} catch {
|
|
2663
3393
|
return 0;
|
|
2664
3394
|
}
|
|
@@ -2773,7 +3503,11 @@ export function electEmitter(projectDir, sessionId) {
|
|
|
2773
3503
|
if (n === 0) target = "fired-1";
|
|
2774
3504
|
else if (n === 1 && isArmed(projectDir, sessionId)) target = "fired-2";
|
|
2775
3505
|
if (!target) return false;
|
|
2776
|
-
try {
|
|
3506
|
+
try {
|
|
3507
|
+
mkdirSync(dir, { recursive: true });
|
|
3508
|
+
} catch {
|
|
3509
|
+
return false;
|
|
3510
|
+
}
|
|
2777
3511
|
|
|
2778
3512
|
// In the ARMED state the arming is the scarce thing, so consuming it must be
|
|
2779
3513
|
// the atomic act. Creating fired-2 first and unlinking `armed` afterwards
|
|
@@ -2783,7 +3517,11 @@ export function electEmitter(projectDir, sessionId) {
|
|
|
2783
3517
|
// both win). unlink() succeeding is what elects here, exactly as create()
|
|
2784
3518
|
// does in the unarmed state.
|
|
2785
3519
|
if (target === "fired-2") {
|
|
2786
|
-
try {
|
|
3520
|
+
try {
|
|
3521
|
+
unlinkSync(join(dir, "armed"));
|
|
3522
|
+
} catch {
|
|
3523
|
+
return false;
|
|
3524
|
+
}
|
|
2787
3525
|
}
|
|
2788
3526
|
try {
|
|
2789
3527
|
writeFileSync(join(dir, target), "", { flag: "wx" });
|
|
@@ -2821,17 +3559,16 @@ export function electEmitter(projectDir, sessionId) {
|
|
|
2821
3559
|
// did; without the second, parallel subagents (which share one session id)
|
|
2822
3560
|
// vote on a name none of them can accept.
|
|
2823
3561
|
|
|
2824
|
-
export const ANCHOR_SETTLE = 5;
|
|
2825
|
-
export const ANCHOR_MARGIN = 10;
|
|
2826
|
-
const ANCHOR_NAME_CELL = 48;
|
|
3562
|
+
export const ANCHOR_SETTLE = 5; // tally at which a key becomes offerable
|
|
3563
|
+
export const ANCHOR_MARGIN = 10; // lead a challenger needs to take over
|
|
3564
|
+
const ANCHOR_NAME_CELL = 48; // a name is typed by hand; keep it typeable
|
|
2827
3565
|
|
|
2828
3566
|
// Which cluster an artifact belongs to: its epic, or the document itself.
|
|
2829
3567
|
// Folders come from the layout, never from a hard-coded set — a vault that
|
|
2830
3568
|
// renames `research/` must not silently stop being nameable.
|
|
2831
3569
|
export function anchorKeyOf(rel, layout) {
|
|
2832
3570
|
if (typeof rel !== "string" || !rel) return null;
|
|
2833
|
-
const folders = (layout && Array.isArray(layout.folders) ? layout.folders : [])
|
|
2834
|
-
.filter((f) => f && f.path);
|
|
3571
|
+
const folders = (layout && Array.isArray(layout.folders) ? layout.folders : []).filter((f) => f && f.path);
|
|
2835
3572
|
const epic = folders.find((f) => f.kind === "epic");
|
|
2836
3573
|
if (epic && rel.startsWith(epic.path + "/")) {
|
|
2837
3574
|
// String ops, never an interpolated RegExp: a layout path is user data, and
|
|
@@ -2845,7 +3582,7 @@ export function anchorKeyOf(rel, layout) {
|
|
|
2845
3582
|
if (f.kind === "epic") continue;
|
|
2846
3583
|
if (rel.startsWith(f.path + "/") && rel.endsWith(".md")) {
|
|
2847
3584
|
const base = rel.slice(f.path.length + 1);
|
|
2848
|
-
if (base.includes("/")) continue;
|
|
3585
|
+
if (base.includes("/")) continue; // only files directly in the folder
|
|
2849
3586
|
// A folder's generated index is not a document: `adr/README.md` would
|
|
2850
3587
|
// otherwise settle an anchor and compose the name "readme".
|
|
2851
3588
|
if (f.readme && base.toLowerCase() === "readme.md") continue;
|
|
@@ -2883,9 +3620,7 @@ export function foldAnchor(state, hit) {
|
|
|
2883
3620
|
let offer = null;
|
|
2884
3621
|
const top = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
|
2885
3622
|
if (top && top[1] >= ANCHOR_SETTLE) {
|
|
2886
|
-
const take = incumbent === null
|
|
2887
|
-
? true
|
|
2888
|
-
: top[0] !== incumbent && top[1] >= (counts[incumbent] || 0) + ANCHOR_MARGIN;
|
|
3623
|
+
const take = incumbent === null ? true : top[0] !== incumbent && top[1] >= (counts[incumbent] || 0) + ANCHOR_MARGIN;
|
|
2889
3624
|
if (take && top[0] !== incumbent) {
|
|
2890
3625
|
incumbent = top[0];
|
|
2891
3626
|
// A moved anchor is not automatically a new NAME. Two documents can share
|
|
@@ -2960,7 +3695,10 @@ export function anchorDir(projectDir, sessionId) {
|
|
|
2960
3695
|
}
|
|
2961
3696
|
|
|
2962
3697
|
function anchorSlot(key, leaf) {
|
|
2963
|
-
const h = createHash("sha1")
|
|
3698
|
+
const h = createHash("sha1")
|
|
3699
|
+
.update(leaf ? `${key}\u0000${leaf}` : key)
|
|
3700
|
+
.digest("hex")
|
|
3701
|
+
.slice(0, 16);
|
|
2964
3702
|
return leaf ? `l${h}` : `k${h}`;
|
|
2965
3703
|
}
|
|
2966
3704
|
|
|
@@ -3007,14 +3745,17 @@ export function readAnchorState(projectDir, sessionId) {
|
|
|
3007
3745
|
try {
|
|
3008
3746
|
raw = readFileSync(join(dir, f), "utf8");
|
|
3009
3747
|
n = statSync(join(dir, slot)).size;
|
|
3010
|
-
} catch {
|
|
3748
|
+
} catch {
|
|
3749
|
+
continue;
|
|
3750
|
+
}
|
|
3011
3751
|
if (!raw || !n) continue;
|
|
3012
3752
|
if (slot.startsWith("k")) {
|
|
3013
3753
|
st.counts[raw] = n;
|
|
3014
3754
|
} else {
|
|
3015
3755
|
const nl = raw.indexOf("\n");
|
|
3016
3756
|
if (nl < 0) continue;
|
|
3017
|
-
const key = raw.slice(0, nl),
|
|
3757
|
+
const key = raw.slice(0, nl),
|
|
3758
|
+
leaf = raw.slice(nl + 1);
|
|
3018
3759
|
st.leaves[key] = { ...(st.leaves[key] || {}), [leaf]: n };
|
|
3019
3760
|
}
|
|
3020
3761
|
}
|
|
@@ -3074,7 +3815,11 @@ export function liveSessionNames(selfSessionId, dir = null) {
|
|
|
3074
3815
|
const root = dir || join(claudeHome(), "sessions");
|
|
3075
3816
|
const out = [];
|
|
3076
3817
|
let files;
|
|
3077
|
-
try {
|
|
3818
|
+
try {
|
|
3819
|
+
files = readdirSync(root);
|
|
3820
|
+
} catch {
|
|
3821
|
+
return out;
|
|
3822
|
+
}
|
|
3078
3823
|
for (const f of files) {
|
|
3079
3824
|
if (!f.endsWith(".json")) continue;
|
|
3080
3825
|
try {
|
|
@@ -3093,7 +3838,11 @@ export function liveSessionNames(selfSessionId, dir = null) {
|
|
|
3093
3838
|
export function ownSessionName(selfSessionId, dir = null) {
|
|
3094
3839
|
const root = dir || join(claudeHome(), "sessions");
|
|
3095
3840
|
let files;
|
|
3096
|
-
try {
|
|
3841
|
+
try {
|
|
3842
|
+
files = readdirSync(root);
|
|
3843
|
+
} catch {
|
|
3844
|
+
return null;
|
|
3845
|
+
}
|
|
3097
3846
|
for (const f of files) {
|
|
3098
3847
|
if (!f.endsWith(".json")) continue;
|
|
3099
3848
|
try {
|
|
@@ -3111,7 +3860,7 @@ export function sessionNameOffer(name, { peers = [], current = null, declined =
|
|
|
3111
3860
|
// Never talk over a name the person chose. `declined` carries names we have
|
|
3112
3861
|
// seen the session wear that we did not compose.
|
|
3113
3862
|
if (current && current !== name && declined.includes(current)) return null;
|
|
3114
|
-
if (current === name) return null;
|
|
3863
|
+
if (current === name) return null; // already wearing it
|
|
3115
3864
|
const taken = new Set(peers.map((p) => p && p.name).filter(Boolean));
|
|
3116
3865
|
if (!taken.has(name)) return { name, qualified: false };
|
|
3117
3866
|
// Taken. Qualify rather than skip: the anchor is still the honest answer, and
|
|
@@ -3138,21 +3887,6 @@ export function sessionNameOfferText(offer, { env = process.env } = {}) {
|
|
|
3138
3887
|
|
|
3139
3888
|
// ─── Frontmatter parsing (minimal) ─────────────────────────────────────
|
|
3140
3889
|
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
const data = {};
|
|
3145
|
-
for (const line of m[1].split("\n")) {
|
|
3146
|
-
const kv = line.match(/^(\w+):\s*(.*)$/);
|
|
3147
|
-
if (!kv) continue;
|
|
3148
|
-
let v = kv[2].trim();
|
|
3149
|
-
if (v === "null") v = null;
|
|
3150
|
-
else if (v.startsWith('"') && v.endsWith('"')) {
|
|
3151
|
-
// JSON.parse round-trips escaped scalars from renderTemplate's _json
|
|
3152
|
-
// form (titles containing quotes); fall back to the bare strip.
|
|
3153
|
-
try { v = JSON.parse(v); } catch { v = v.slice(1, -1); }
|
|
3154
|
-
}
|
|
3155
|
-
data[kv[1]] = v;
|
|
3156
|
-
}
|
|
3157
|
-
return { data, body: md.slice(m[0].length) };
|
|
3158
|
-
}
|
|
3890
|
+
// Moved to stages.mjs (Node-free) so the band module parses frontmatter by
|
|
3891
|
+
// the same rule; re-exported here for every existing caller.
|
|
3892
|
+
export { parseFrontmatter };
|