projectstore-codex 0.0.1 → 0.28.1
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/.codex-plugin/plugin.json +48 -0
- package/README.md +15 -7
- package/bin/projectstore-codex.mjs +88 -0
- package/hooks/hooks.json +59 -0
- package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
- package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
- package/node_modules/projectstore/.mcp.json +14 -0
- package/node_modules/projectstore/AGENTS.md +26 -0
- package/node_modules/projectstore/LICENSE +21 -0
- package/node_modules/projectstore/README.md +284 -0
- package/node_modules/projectstore/agents/archaeologist.md +76 -0
- package/node_modules/projectstore/agents/clerk.md +93 -0
- package/node_modules/projectstore/agents/critic.md +94 -0
- package/node_modules/projectstore/agents/librarian.md +81 -0
- package/node_modules/projectstore/agents/planner.md +80 -0
- package/node_modules/projectstore/agents/reviewer.md +98 -0
- package/node_modules/projectstore/bin/projectstore.mjs +7 -0
- package/node_modules/projectstore/commands/adr.md +57 -0
- package/node_modules/projectstore/commands/agents.md +180 -0
- package/node_modules/projectstore/commands/bind.md +128 -0
- package/node_modules/projectstore/commands/codemap.md +50 -0
- package/node_modules/projectstore/commands/concept.md +17 -0
- package/node_modules/projectstore/commands/doctor.md +166 -0
- package/node_modules/projectstore/commands/epic.md +40 -0
- package/node_modules/projectstore/commands/graph.md +56 -0
- package/node_modules/projectstore/commands/kanban.md +40 -0
- package/node_modules/projectstore/commands/meeting.md +17 -0
- package/node_modules/projectstore/commands/reconcile.md +73 -0
- package/node_modules/projectstore/commands/research.md +17 -0
- package/node_modules/projectstore/commands/review.md +89 -0
- package/node_modules/projectstore/commands/runbook.md +17 -0
- package/node_modules/projectstore/commands/scaffold.md +23 -0
- package/node_modules/projectstore/commands/search.md +22 -0
- package/node_modules/projectstore/commands/spec.md +91 -0
- package/node_modules/projectstore/commands/status.md +27 -0
- package/node_modules/projectstore/commands/statusline.md +46 -0
- package/node_modules/projectstore/commands/story.md +113 -0
- package/node_modules/projectstore/docs/extending.md +172 -0
- package/node_modules/projectstore/docs/getting-started.md +133 -0
- package/node_modules/projectstore/docs/harnesses.md +163 -0
- package/node_modules/projectstore/docs/how-it-works.md +263 -0
- package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
- package/node_modules/projectstore/docs/images/loop.svg +93 -0
- package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
- package/node_modules/projectstore/docs/images/team-light.svg +79 -0
- package/node_modules/projectstore/docs/images/team.svg +79 -0
- package/node_modules/projectstore/harnesses/claude-code.json +483 -0
- package/node_modules/projectstore/harnesses/codex.json +332 -0
- package/node_modules/projectstore/hooks/hooks.json +59 -0
- package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
- package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
- package/node_modules/projectstore/hooks/session-start.mjs +301 -0
- package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
- package/node_modules/projectstore/package.json +70 -0
- package/node_modules/projectstore/scaffold/checklists.json +88 -0
- package/node_modules/projectstore/scaffold/headings.json +171 -0
- package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
- package/node_modules/projectstore/scripts/binding.mjs +165 -0
- package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
- package/node_modules/projectstore/scripts/cli.mjs +595 -0
- package/node_modules/projectstore/scripts/codemap.mjs +99 -0
- package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
- package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
- package/node_modules/projectstore/scripts/draft.mjs +261 -0
- package/node_modules/projectstore/scripts/graph.mjs +219 -0
- package/node_modules/projectstore/scripts/harness.mjs +608 -0
- package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
- package/node_modules/projectstore/scripts/kanban.mjs +174 -0
- package/node_modules/projectstore/scripts/lib.mjs +3085 -0
- package/node_modules/projectstore/scripts/mcp.mjs +391 -0
- package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
- package/node_modules/projectstore/scripts/provenance.mjs +375 -0
- package/node_modules/projectstore/scripts/query.mjs +490 -0
- package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
- package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
- package/node_modules/projectstore/scripts/statusline.mjs +253 -0
- package/node_modules/projectstore/scripts/story-section.mjs +209 -0
- package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
- package/node_modules/projectstore/scripts/tokens.mjs +449 -0
- package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
- package/node_modules/projectstore/scripts/version-guard.mjs +255 -0
- package/node_modules/projectstore/scripts/worktree.mjs +109 -0
- package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
- package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
- package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
- package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
- package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
- package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/de/strings.json +6 -0
- package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/en/strings.json +6 -0
- package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/es/strings.json +6 -0
- package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/fr/strings.json +6 -0
- package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/ru/strings.json +6 -0
- package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
- package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
- package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
- package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
- package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
- package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
- package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
- package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
- package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
- package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
- package/node_modules/projectstore/templates/zh/strings.json +6 -0
- package/package.json +36 -14
- package/plugin.json +53 -0
- package/skills/projectstore-adr/SKILL.md +76 -0
- package/skills/projectstore-agents/SKILL.md +50 -0
- package/skills/projectstore-archaeologist/SKILL.md +109 -0
- package/skills/projectstore-bind/SKILL.md +44 -0
- package/skills/projectstore-clerk/SKILL.md +126 -0
- package/skills/projectstore-codemap/SKILL.md +69 -0
- package/skills/projectstore-concept/SKILL.md +36 -0
- package/skills/projectstore-critic/SKILL.md +127 -0
- package/skills/projectstore-decision-detector/SKILL.md +59 -0
- package/skills/projectstore-doctor/SKILL.md +33 -0
- package/skills/projectstore-epic/SKILL.md +59 -0
- package/skills/projectstore-graph/SKILL.md +75 -0
- package/skills/projectstore-kanban/SKILL.md +60 -0
- package/skills/projectstore-librarian/SKILL.md +114 -0
- package/skills/projectstore-meeting/SKILL.md +36 -0
- package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
- package/skills/projectstore-planner/SKILL.md +113 -0
- package/skills/projectstore-reconcile/SKILL.md +92 -0
- package/skills/projectstore-research/SKILL.md +36 -0
- package/skills/projectstore-review/SKILL.md +108 -0
- package/skills/projectstore-reviewer/SKILL.md +131 -0
- package/skills/projectstore-runbook/SKILL.md +36 -0
- package/skills/projectstore-scaffold/SKILL.md +42 -0
- package/skills/projectstore-search/SKILL.md +41 -0
- package/skills/projectstore-spec/SKILL.md +110 -0
- package/skills/projectstore-status/SKILL.md +47 -0
- package/skills/projectstore-statusline/SKILL.md +29 -0
- package/skills/projectstore-story/SKILL.md +132 -0
- package/skills/projectstore-story-completion/SKILL.md +69 -0
- package/skills/projectstore-vault-communication/SKILL.md +115 -0
|
@@ -0,0 +1,3085 @@
|
|
|
1
|
+
// projectstore — shared helpers used by commands and hooks.
|
|
2
|
+
// Pure node, no external deps. Keep this single-file & dependency-free
|
|
3
|
+
// so plugin install does not require npm install.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, writeSync, appendFileSync, existsSync, readdirSync, statSync, lstatSync, mkdirSync, utimesSync, unlinkSync, renameSync, rmSync, realpathSync, cpSync } from "node:fs";
|
|
6
|
+
import { readFile as readFileAsync } from "node:fs/promises";
|
|
7
|
+
import { join, dirname, basename, resolve, relative, isAbsolute } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { hostname, homedir } from "node:os";
|
|
10
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
11
|
+
import {
|
|
12
|
+
projectRoot as harnessProjectRoot,
|
|
13
|
+
pluginRoot as harnessPluginRoot,
|
|
14
|
+
agentHome as harnessAgentHome,
|
|
15
|
+
configPath as harnessConfigPath,
|
|
16
|
+
projectConfigDir as harnessProjectConfigDir,
|
|
17
|
+
detectHarnessId,
|
|
18
|
+
adoptHookInput,
|
|
19
|
+
resetHookInput,
|
|
20
|
+
overlayId,
|
|
21
|
+
hostSettingsPath,
|
|
22
|
+
layoutPaths,
|
|
23
|
+
pickExisting,
|
|
24
|
+
LAYOUT,
|
|
25
|
+
RUNTIME_GITIGNORE_HEADER,
|
|
26
|
+
runtimeEnvNames,
|
|
27
|
+
sourceWriteTools,
|
|
28
|
+
isWriteTool as harnessIsWriteTool,
|
|
29
|
+
toolPaths as harnessToolPaths,
|
|
30
|
+
sourceHarness,
|
|
31
|
+
} from "./harness.mjs";
|
|
32
|
+
|
|
33
|
+
// ─── Paths ─────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
// The branded environment names live in harnesses/<id>.json and are read by
|
|
36
|
+
// harness.mjs only; these three keep the names the rest of the plugin already
|
|
37
|
+
// calls.
|
|
38
|
+
export function projectRoot() {
|
|
39
|
+
return harnessProjectRoot(process.env);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function pluginRoot() {
|
|
43
|
+
return harnessPluginRoot(process.env);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function configPath() {
|
|
47
|
+
return harnessConfigPath(projectRoot(), process.env);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The layout resolver and its constants, re-exported so hooks and scripts
|
|
51
|
+
// import one module (the layout ADR, 2026-09-06). The active harness's id,
|
|
52
|
+
// for the paths keyed by it (state/<id>/…).
|
|
53
|
+
export { layoutPaths, pickExisting, LAYOUT, RUNTIME_GITIGNORE_HEADER, hostSettingsPath, overlayId };
|
|
54
|
+
|
|
55
|
+
// A hook's payload carries the `cwd` of the session that fired it, and on a
|
|
56
|
+
// harness that exports no project-dir variable it is the only answer better
|
|
57
|
+
// than "whatever directory this process started in". adoptHookInput lives in
|
|
58
|
+
// harness.mjs, which may import node builtins only; readStdinJson lives here.
|
|
59
|
+
// So the pairing can only be composed here, and it is re-exported rather than
|
|
60
|
+
// wrapped so each hook shows its own ordering at its own entry point.
|
|
61
|
+
export { adoptHookInput, resetHookInput };
|
|
62
|
+
|
|
63
|
+
// ─── Harness overlays (the layout ADR decision 3; layout spec contracts 2–4) ──
|
|
64
|
+
//
|
|
65
|
+
// <project>/.projectstore/harness/<id>.json carries ONE thing: the agents block
|
|
66
|
+
// for that harness — agents.default.model and agents.per_agent.<name>.model.
|
|
67
|
+
// Every other key is ignored on read and named for doctor (rejected). The
|
|
68
|
+
// binding never carries agents: ADR-008's two-term chain is read from here.
|
|
69
|
+
export function readOverlayAt(projectDir, id = overlayId()) {
|
|
70
|
+
// No id (no manifest at all): nothing is read, and the writer below refuses
|
|
71
|
+
// the same id rather than inventing a file name for it.
|
|
72
|
+
const path = id ? layoutPaths(projectDir).overlay(id) : null;
|
|
73
|
+
const out = { id: id || null, path, present: Boolean(path) && existsSync(path), unparseable: false, agents: { default: null, per_agent: {} }, rejected: [], raw: null };
|
|
74
|
+
if (!out.present) return out;
|
|
75
|
+
let raw;
|
|
76
|
+
try { raw = JSON.parse(readFileSync(path, "utf8")); } catch { out.unparseable = true; return out; }
|
|
77
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) { out.unparseable = true; return out; }
|
|
78
|
+
out.raw = raw;
|
|
79
|
+
for (const k of Object.keys(raw)) if (k !== "agents") out.rejected.push(k);
|
|
80
|
+
const a = raw.agents;
|
|
81
|
+
if (a === undefined) return out;
|
|
82
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) { out.rejected.push("agents"); return out; }
|
|
83
|
+
for (const k of Object.keys(a)) if (k !== "default" && k !== "per_agent") out.rejected.push(`agents.${k}`);
|
|
84
|
+
if (a.default !== undefined) {
|
|
85
|
+
if (a.default && typeof a.default === "object") {
|
|
86
|
+
for (const k of Object.keys(a.default)) if (k !== "model") out.rejected.push(`agents.default.${k}`);
|
|
87
|
+
if (typeof a.default.model === "string" && a.default.model) out.agents.default = a.default.model;
|
|
88
|
+
} else out.rejected.push("agents.default");
|
|
89
|
+
}
|
|
90
|
+
if (a.per_agent !== undefined) {
|
|
91
|
+
if (a.per_agent && typeof a.per_agent === "object" && !Array.isArray(a.per_agent)) {
|
|
92
|
+
for (const [name, v] of Object.entries(a.per_agent)) {
|
|
93
|
+
if (!v || typeof v !== "object") { out.rejected.push(`agents.per_agent.${name}`); continue; }
|
|
94
|
+
for (const k of Object.keys(v)) if (k !== "model") out.rejected.push(`agents.per_agent.${name}.${k}`);
|
|
95
|
+
if (typeof v.model === "string" && v.model) out.agents.per_agent[name] = v.model;
|
|
96
|
+
}
|
|
97
|
+
} else out.rejected.push("agents.per_agent");
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ADR-008's two terms, from the active harness's overlay: per-agent, else the
|
|
103
|
+
// default, else null — null means "pass nothing, the agent's frontmatter decides".
|
|
104
|
+
export function resolveAgentModel(projectDir, name, { harness = overlayId() } = {}) {
|
|
105
|
+
const o = readOverlayAt(projectDir, harness);
|
|
106
|
+
const per = o.agents.per_agent[name];
|
|
107
|
+
if (per) return { name, model: per, source: "per_agent", overlay: o.path, harness };
|
|
108
|
+
if (o.agents.default) return { name, model: o.agents.default, source: "default", overlay: o.path, harness };
|
|
109
|
+
return { name, model: null, source: null, overlay: o.path, harness };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The one writer of an overlay: rewrites its agents block, keeps any other key
|
|
113
|
+
// as it found it (doctor names them; this never silently drops a user's key).
|
|
114
|
+
export function writeOverlayAt(projectDir, id, agents) {
|
|
115
|
+
if (!id) throw new Error("writeOverlayAt: the overlay's harness id is required");
|
|
116
|
+
const path = layoutPaths(projectDir).overlay(id);
|
|
117
|
+
let raw = {};
|
|
118
|
+
try { raw = JSON.parse(readFileSync(path, "utf8")); } catch {}
|
|
119
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) raw = {};
|
|
120
|
+
const block = {};
|
|
121
|
+
if (agents.default) block.default = { model: agents.default };
|
|
122
|
+
const names = Object.keys(agents.per_agent || {}).sort();
|
|
123
|
+
if (names.length) block.per_agent = Object.fromEntries(names.map((n) => [n, { model: agents.per_agent[n] }]));
|
|
124
|
+
const next = { ...raw };
|
|
125
|
+
if (Object.keys(block).length) next.agents = block; else delete next.agents;
|
|
126
|
+
ensureRuntimeDir(projectDir);
|
|
127
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
128
|
+
writeFileAtomic(path, JSON.stringify(next, null, 2) + "\n", { sweep: false });
|
|
129
|
+
return path;
|
|
130
|
+
}
|
|
131
|
+
export function activeHarnessId() {
|
|
132
|
+
return detectHarnessId(process.env) || "harness";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// .gitignore files we share with other writers (the vault's sessions dir, the
|
|
136
|
+
// project's .projectstore/) are merged by line: each writer ensures its lines
|
|
137
|
+
// exist and never rewrites what is there (layout spec, contract 5).
|
|
138
|
+
export function ensureGitignoreLines(file, lines, header = null) {
|
|
139
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
140
|
+
let cur = "";
|
|
141
|
+
try { cur = readFileSync(file, "utf8"); } catch {}
|
|
142
|
+
const have = new Set(cur.split(/\r?\n/).map((l) => l.trim()));
|
|
143
|
+
const add = lines.filter((l) => !have.has(l));
|
|
144
|
+
if (!add.length) return false;
|
|
145
|
+
const prefix = cur ? (cur.endsWith("\n") ? cur : cur + "\n") : (header ? `# ${header}\n` : "");
|
|
146
|
+
writeFileSync(file, prefix + add.join("\n") + "\n", "utf8");
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Move one path inside a project — the migration's only mechanism (layout
|
|
151
|
+
// spec, contract 6). Absent source: nothing to do. Existing target: refused,
|
|
152
|
+
// never overwritten. Both paths must lie inside `within`.
|
|
153
|
+
// Move a legacy state directory's contents into the new one, per file, with
|
|
154
|
+
// the collision policy of the layout spec (contract 6): a session file that
|
|
155
|
+
// exists on both sides keeps the newer mtime; a per-session directory
|
|
156
|
+
// (<sid>.paths/, <sid>.fired/) and the renderer's breadcrumb keep the NEW side;
|
|
157
|
+
// everything else moves when the target is absent. Returns what happened.
|
|
158
|
+
export function moveStateDir(from, to, within) {
|
|
159
|
+
const out = { moved: [], kept: [], replaced: [], dropped: [] };
|
|
160
|
+
if (!existsSync(from)) return out;
|
|
161
|
+
mkdirSync(to, { recursive: true });
|
|
162
|
+
for (const name of readdirSync(from)) {
|
|
163
|
+
if (name === ".gitignore") continue;
|
|
164
|
+
const src = join(from, name), dst = join(to, name);
|
|
165
|
+
let st; try { st = lstatSync(src); } catch { continue; }
|
|
166
|
+
if (st.isSymbolicLink()) { out.dropped.push(name); continue; }
|
|
167
|
+
if (!existsSync(dst)) { movePath(src, dst, within); out.moved.push(name); continue; }
|
|
168
|
+
if (st.isDirectory() || name.startsWith(".")) { rmSync(src, { recursive: true, force: true }); out.kept.push(name); continue; }
|
|
169
|
+
const newer = st.mtimeMs > statSync(dst).mtimeMs;
|
|
170
|
+
if (newer) { rmSync(dst, { force: true }); movePath(src, dst, within); out.replaced.push(name); }
|
|
171
|
+
else { rmSync(src, { force: true }); out.kept.push(name); }
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The legacy entry log's lines go BEFORE the new log's (they are older), and
|
|
177
|
+
// the legacy file is removed; an absent side is fine.
|
|
178
|
+
export function mergeEntryLog(from, to, within) {
|
|
179
|
+
if (!existsSync(from)) return "absent";
|
|
180
|
+
const inside = (p) => { const r = relative(resolve(within), resolve(p)); return r !== "" && !r.startsWith("..") && !isAbsolute(r); };
|
|
181
|
+
if (!inside(from) || !inside(to)) throw new Error(`mergeEntryLog: ${from} → ${to} leaves ${within}`);
|
|
182
|
+
const old = readFileSync(from, "utf8");
|
|
183
|
+
let cur = ""; try { cur = readFileSync(to, "utf8"); } catch {}
|
|
184
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
185
|
+
const glue = old && !old.endsWith("\n") ? "\n" : "";
|
|
186
|
+
writeFileAtomic(to, old + glue + cur, { sweep: false });
|
|
187
|
+
rmSync(from, { force: true });
|
|
188
|
+
return cur ? "merged" : "moved";
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Remove a path inside the project — the layout migration's deletes (the two
|
|
192
|
+
// legacy markers, an emptied legacy runtime directory, a legacy launcher
|
|
193
|
+
// nothing names). Refuses anything outside `within`.
|
|
194
|
+
export function removeInside(path, within, { recursive = false } = {}) {
|
|
195
|
+
const r = relative(resolve(within), resolve(path));
|
|
196
|
+
if (r === "" || r.startsWith("..") || isAbsolute(r)) throw new Error(`removeInside: ${path} is not inside ${within}`);
|
|
197
|
+
if (!existsSync(path)) return false;
|
|
198
|
+
rmSync(path, { recursive, force: true });
|
|
199
|
+
return true;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function movePath(from, to, within) {
|
|
203
|
+
const inside = (p) => { const r = relative(resolve(within), resolve(p)); return r !== "" && !r.startsWith("..") && !isAbsolute(r); };
|
|
204
|
+
if (!inside(from) || !inside(to)) throw new Error(`movePath: ${from} → ${to} leaves ${within}`);
|
|
205
|
+
if (!existsSync(from)) return "absent";
|
|
206
|
+
if (existsSync(to)) return "target-exists";
|
|
207
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
208
|
+
renameSync(from, to);
|
|
209
|
+
return "moved";
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ─── Config ────────────────────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
// Config of an arbitrary project, not necessarily this one. The worktree
|
|
215
|
+
// inheritance path needs to read the PARENT checkout's config, and reading it
|
|
216
|
+
// through a second hand-rolled parse is how the two would drift on the one
|
|
217
|
+
// behaviour that matters here: corrupt JSON reads as "not bound", never throws.
|
|
218
|
+
export function readConfigAt(projectDir) {
|
|
219
|
+
const p = harnessConfigPath(projectDir, process.env);
|
|
220
|
+
if (!existsSync(p)) return null;
|
|
221
|
+
try {
|
|
222
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
223
|
+
} catch (e) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function readConfig() {
|
|
229
|
+
return readConfigAt(projectRoot());
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Entry-file guard ──────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
// `if (isMain(import.meta.url)) main()` — a generator runs only as the entry
|
|
235
|
+
// file, so importing it prints nothing (the MCP server's stdout is its
|
|
236
|
+
// protocol channel). argv[1] is compared resolved AND realpath'd: ESM
|
|
237
|
+
// realpaths import.meta.url, so a plugin root reached through a symlink
|
|
238
|
+
// would otherwise never match and the generator would print nothing to a
|
|
239
|
+
// caller that then reports "unparseable generator output".
|
|
240
|
+
export function isMain(metaUrl) {
|
|
241
|
+
const argv1 = process.argv[1];
|
|
242
|
+
if (!argv1) return false;
|
|
243
|
+
const here = fileURLToPath(metaUrl);
|
|
244
|
+
if (resolve(argv1) === here) return true;
|
|
245
|
+
try { return realpathSync(argv1) === here; } catch { return false; }
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── Atomic file writes (spec: atomic-regeneration-of-derived-views) ──
|
|
249
|
+
|
|
250
|
+
// Replace a file's content via a same-directory temp + rename. Parallel
|
|
251
|
+
// readers see the old bytes or the new bytes, never a torn file — a torn
|
|
252
|
+
// read of a generated view is a corrupt board; of the statusline launcher,
|
|
253
|
+
// a SyntaxError, i.e. a blank HUD frame. The temp name is doubly
|
|
254
|
+
// load-bearing: the dot prefix hides it from Obsidian and doctor's vault
|
|
255
|
+
// walk, and the `.tmp` suffix is excluded from iCloud sync — temps never
|
|
256
|
+
// leave this machine, which is exactly what makes pid-liveness a sound
|
|
257
|
+
// staleness test in sweepOrphanTemps. Never mkdirs (callers own their
|
|
258
|
+
// directories); the rename gives the target the temp's file mode; on win32
|
|
259
|
+
// a rename over a concurrently-open target can fail EPERM — callers report
|
|
260
|
+
// it and the next regeneration repairs. May throw: callers report or
|
|
261
|
+
// degrade; the helper does not swallow.
|
|
262
|
+
export function writeFileAtomic(p, content, { sweep = true } = {}) {
|
|
263
|
+
const dir = dirname(p);
|
|
264
|
+
if (sweep) sweepOrphanTemps(dir);
|
|
265
|
+
const tmp = join(dir, `.${basename(p)}.${process.pid}.tmp`);
|
|
266
|
+
try {
|
|
267
|
+
writeFileSync(tmp, content, "utf8");
|
|
268
|
+
renameSync(tmp, p);
|
|
269
|
+
} catch (e) {
|
|
270
|
+
try { unlinkSync(tmp); } catch {}
|
|
271
|
+
throw e;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Write metadata into a descriptor the caller acquired with O_EXCL. The
|
|
276
|
+
// exclusive create is the lock; keeping this tiny write in lib preserves the
|
|
277
|
+
// repository's single write boundary without weakening the atomic lock race.
|
|
278
|
+
export function writeExclusiveMetadata(fd, value) {
|
|
279
|
+
return writeSync(fd, typeof value === "string" ? value : JSON.stringify(value) + "\n");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Crash orphans (SIGKILL, power loss between write and rename) are invisible
|
|
283
|
+
// to every reader by design, so nothing else ever removes them. The sweep
|
|
284
|
+
// runs where writes are frequent enough to matter — reconcile --write's
|
|
285
|
+
// vault directories; the sweep:false writers (statusline paths) can strand
|
|
286
|
+
// an orphan forever, accepted: dot-prefixed, bytes-sized, machine-local.
|
|
287
|
+
// Sweep only temps whose embedded pid is dead: ESRCH ⇒ dead; EPERM ⇒ alive
|
|
288
|
+
// but not ours — a live concurrent writer keeps its temp. Pid reuse can make a dead
|
|
289
|
+
// orphan look alive; accepted (a lingering hidden temp, never data loss) —
|
|
290
|
+
// an mtime heuristic would reintroduce the distributed-clock problem the
|
|
291
|
+
// `.tmp` iCloud exclusion exists to avoid. The strict shape (dot prefix,
|
|
292
|
+
// numeric pid, `.tmp`) can never match `.gitignore` and friends.
|
|
293
|
+
function sweepOrphanTemps(dir) {
|
|
294
|
+
let names;
|
|
295
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
296
|
+
for (const n of names) {
|
|
297
|
+
const m = n.match(/^\..+\.(\d+)\.tmp$/);
|
|
298
|
+
if (!m) continue;
|
|
299
|
+
const pid = parseInt(m[1], 10);
|
|
300
|
+
if (!pid || pid === process.pid) continue;
|
|
301
|
+
try {
|
|
302
|
+
process.kill(pid, 0); // returns ⇒ alive; EPERM ⇒ alive, not ours
|
|
303
|
+
} catch (e) {
|
|
304
|
+
if (e.code === "ESRCH") {
|
|
305
|
+
try { unlinkSync(join(dir, n)); } catch {}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ─── Installed-plugin resolution ───────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
// The harness's config directory. Almost always ~/.claude, but the harness's
|
|
314
|
+
// home variable relocates it — and a consumer that hardcodes the default
|
|
315
|
+
// silently resolves nothing for those users instead of failing loudly. The
|
|
316
|
+
// name is kept for its callers; the variable comes from the manifest.
|
|
317
|
+
export function claudeHome(home = homedir()) {
|
|
318
|
+
return harnessAgentHome(process.env, home);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// The release triple only: 0.28.0-rc.2 and 0.28.0 compare equal. Right for
|
|
322
|
+
// "which release line" (the layout window's sunset); wrong for "which build is
|
|
323
|
+
// newer" — that is cmpPrecedence.
|
|
324
|
+
export function cmpVersion(a, b) {
|
|
325
|
+
const A = String(a || "0").split(".").map((n) => parseInt(n, 10) || 0);
|
|
326
|
+
const B = String(b || "0").split(".").map((n) => parseInt(n, 10) || 0);
|
|
327
|
+
for (let i = 0; i < 3; i++) if ((A[i] || 0) !== (B[i] || 0)) return (A[i] || 0) - (B[i] || 0);
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Semver 2.0 precedence: 0.28.0-rc.2 < 0.28.0-rc.3 < 0.28.0. A registration
|
|
332
|
+
// made at a release candidate read as current against the release while the
|
|
333
|
+
// comparison stopped at the triple, so `upgrade` never refreshed it (the
|
|
334
|
+
// critic's probe, 2026-10-03). Build metadata (`+…`) never ranks.
|
|
335
|
+
export function cmpPrecedence(a, b) {
|
|
336
|
+
const parse = (v) => {
|
|
337
|
+
const s = String(v || "0").split("+")[0];
|
|
338
|
+
const dash = s.indexOf("-");
|
|
339
|
+
return { core: (dash < 0 ? s : s.slice(0, dash)).split(".").map((n) => parseInt(n, 10) || 0), pre: dash < 0 ? [] : s.slice(dash + 1).split(".") };
|
|
340
|
+
};
|
|
341
|
+
const A = parse(a), B = parse(b);
|
|
342
|
+
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);
|
|
343
|
+
// A release outranks every candidate for it.
|
|
344
|
+
if (!A.pre.length || !B.pre.length) return B.pre.length - A.pre.length;
|
|
345
|
+
for (let i = 0; i < Math.max(A.pre.length, B.pre.length); i++) {
|
|
346
|
+
if (i >= A.pre.length) return -1;
|
|
347
|
+
if (i >= B.pre.length) return 1;
|
|
348
|
+
const x = A.pre[i], y = B.pre[i];
|
|
349
|
+
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
|
|
350
|
+
if (nx && ny) { if (Number(x) !== Number(y)) return Number(x) - Number(y); continue; }
|
|
351
|
+
// A numeric identifier ranks below an alphanumeric one.
|
|
352
|
+
if (nx !== ny) return nx ? -1 : 1;
|
|
353
|
+
if (x !== y) return x < y ? -1 : 1;
|
|
354
|
+
}
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Where the CURRENTLY installed projectstore lives, per Claude Code's own
|
|
359
|
+
// plugin registry. The cache path carries the version
|
|
360
|
+
// (…/plugins/cache/<marketplace>/projectstore/<version>), so anything that
|
|
361
|
+
// pins it goes stale on the next update — this is how such consumers ask what
|
|
362
|
+
// is real right now. Newest install that is actually on disk wins; entries
|
|
363
|
+
// pointing at wiped directories are ignored.
|
|
364
|
+
// `preferFamily` (a …/<marketplace>/projectstore directory) wins over
|
|
365
|
+
// recency, matching the launcher's own ordering — otherwise doctor could
|
|
366
|
+
// report drift against an install the launcher would never load.
|
|
367
|
+
// Returns { path, version } or null (dev checkout, --plugin-dir, no registry).
|
|
368
|
+
// Every projectstore registration the harness's registry holds — one per
|
|
369
|
+
// marketplace key and scope — whether or not its install is still on disk.
|
|
370
|
+
// The registry is a list, and contract 17 of the install spec (version
|
|
371
|
+
// drift across registrations) needs the list; installedPluginRoot folds it.
|
|
372
|
+
// The host's enablement of a plugin, user settings first and the project's
|
|
373
|
+
// settings over it (a project may silence a user-scope plugin). Absent → true.
|
|
374
|
+
export function pluginEnabled(key, home = homedir(), projectDir = null) {
|
|
375
|
+
const read = (p) => { try { return JSON.parse(readFileSync(p, "utf8")); } catch { return null; } };
|
|
376
|
+
let enabled = true;
|
|
377
|
+
const user = read(join(claudeHome(home), "settings.json"));
|
|
378
|
+
if (user && user.enabledPlugins && Object.hasOwn(user.enabledPlugins, key)) enabled = user.enabledPlugins[key] !== false;
|
|
379
|
+
if (projectDir) {
|
|
380
|
+
// The committed project file, then the checkout's local one over it — the
|
|
381
|
+
// host's own precedence (measured 2026-09-05, --scope local).
|
|
382
|
+
for (const f of ["settings.json", "settings.local.json"]) {
|
|
383
|
+
const proj = read(join(projectDir, harnessProjectConfigDir(), f));
|
|
384
|
+
if (proj && proj.enabledPlugins && Object.hasOwn(proj.enabledPlugins, key)) enabled = proj.enabledPlugins[key] !== false;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return enabled;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Every projectstore registration the host knows, with whether it is enabled
|
|
391
|
+
// (for the project, when one is named): the registry keeps a disabled row,
|
|
392
|
+
// and a copy nobody runs is not a copy (install spec contract 17, amended).
|
|
393
|
+
export function installedPluginEntries(home = homedir(), projectDir = null) {
|
|
394
|
+
const out = [];
|
|
395
|
+
let reg;
|
|
396
|
+
try {
|
|
397
|
+
reg = JSON.parse(readFileSync(join(claudeHome(home), "plugins", "installed_plugins.json"), "utf8"));
|
|
398
|
+
} catch {
|
|
399
|
+
return out;
|
|
400
|
+
}
|
|
401
|
+
for (const [key, list] of Object.entries((reg && reg.plugins) || {})) {
|
|
402
|
+
if (key !== "projectstore" && !key.startsWith("projectstore@")) continue;
|
|
403
|
+
for (const e of Array.isArray(list) ? list : [list]) {
|
|
404
|
+
const path = e && e.installPath;
|
|
405
|
+
if (typeof path !== "string") continue;
|
|
406
|
+
out.push({
|
|
407
|
+
key,
|
|
408
|
+
path,
|
|
409
|
+
scope: typeof e.scope === "string" ? e.scope : null,
|
|
410
|
+
version: typeof e.version === "string" ? e.version : null,
|
|
411
|
+
at: Date.parse((e && e.lastUpdated) || "") || 0,
|
|
412
|
+
present: existsSync(join(path, "scripts", "statusline.mjs")),
|
|
413
|
+
enabled: pluginEnabled(key, home, projectDir),
|
|
414
|
+
projectPath: typeof e.projectPath === "string" ? e.projectPath : null,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// A pure PATH walk for a host binary — a read, not a subprocess: the analysers
|
|
422
|
+
// run on every doctor call. Returns the absolute path or null.
|
|
423
|
+
export function whichOnPath(name, env = process.env) {
|
|
424
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
425
|
+
const exts = process.platform === "win32" ? ["", ".cmd", ".exe", ".bat"] : [""];
|
|
426
|
+
for (const dir of String(env.PATH || "").split(sep).filter(Boolean)) {
|
|
427
|
+
for (const ext of exts) {
|
|
428
|
+
const p = join(dir, name + ext);
|
|
429
|
+
try { const st = statSync(p); if (st.isFile() && (process.platform === "win32" || (st.mode & 0o111))) return p; } catch {}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// The files a package ships, as the release gate defines them — package.json
|
|
436
|
+
// files[] plus npm's always-included package.json, README and LICENSE — copied
|
|
437
|
+
// from `from` to `to`. Never node_modules, never a symlink (a symlink is a
|
|
438
|
+
// path outside the tree). Returns the relative paths copied, sorted.
|
|
439
|
+
export function packageFiles(root) {
|
|
440
|
+
let pkg;
|
|
441
|
+
try { pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")); } catch { return []; }
|
|
442
|
+
const out = new Set(["package.json"]);
|
|
443
|
+
for (const n of readdirSync(root)) if (/^(README|LICEN[CS]E)(\.|$)/i.test(n)) out.add(n);
|
|
444
|
+
const walk = (rel) => {
|
|
445
|
+
const abs = join(root, rel);
|
|
446
|
+
let st; try { st = lstatSync(abs); } catch { return; }
|
|
447
|
+
if (st.isSymbolicLink()) return;
|
|
448
|
+
if (st.isDirectory()) { for (const n of readdirSync(abs)) if (n !== "node_modules" && !n.startsWith(".DS_Store")) walk(rel ? `${rel}/${n}` : n); }
|
|
449
|
+
else out.add(rel);
|
|
450
|
+
};
|
|
451
|
+
for (const f of pkg.files || []) walk(f.replace(/\/+$/, ""));
|
|
452
|
+
return [...out].sort();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// The one recursive delete the installer makes: a registration directory of
|
|
456
|
+
// ours (install spec contract 13, amended 2026-09-05). The caller has already
|
|
457
|
+
// proven the manifest is ours; this refuses anything outside the harness home.
|
|
458
|
+
export function removeOwnTree(dir, home = homedir()) {
|
|
459
|
+
const norm = (s) => String(s || "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
460
|
+
if (!norm(dir).startsWith(norm(claudeHome(home)) + "/")) throw new Error(`removeOwnTree: ${dir} is not under the harness home`);
|
|
461
|
+
rmSync(dir, { recursive: true, force: true });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function copyPackageTree(from, to) {
|
|
465
|
+
const files = packageFiles(from);
|
|
466
|
+
for (const rel of files) {
|
|
467
|
+
const dst = join(to, rel);
|
|
468
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
469
|
+
cpSync(join(from, rel), dst);
|
|
470
|
+
}
|
|
471
|
+
return files;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// Every regular file under a directory, relative, sorted; symlinks skipped.
|
|
475
|
+
export function treeFiles(dir) {
|
|
476
|
+
const out = [];
|
|
477
|
+
const walk = (rel) => {
|
|
478
|
+
const abs = rel ? join(dir, rel) : dir;
|
|
479
|
+
let st; try { st = lstatSync(abs); } catch { return; }
|
|
480
|
+
if (st.isSymbolicLink()) return;
|
|
481
|
+
if (st.isDirectory()) { for (const n of readdirSync(abs)) if (n !== "node_modules" && !n.startsWith(".DS_Store")) walk(rel ? `${rel}/${n}` : n); }
|
|
482
|
+
else out.push(rel);
|
|
483
|
+
};
|
|
484
|
+
walk("");
|
|
485
|
+
return out.sort();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// The digest a registration's provenance field carries over its payload: the
|
|
489
|
+
// file count and one sha256 over "relpath\nsha256(content)" lines in sorted
|
|
490
|
+
// order. Computed from the package's packlist before the copy and from the
|
|
491
|
+
// directory after it; unequal means a copy that did not finish, or a hand edit.
|
|
492
|
+
export function filesDigest(root, files) {
|
|
493
|
+
const h = createHash("sha256");
|
|
494
|
+
for (const rel of files) {
|
|
495
|
+
h.update(rel + "\n");
|
|
496
|
+
h.update(createHash("sha256").update(readFileSync(join(root, rel))).digest("hex") + "\n");
|
|
497
|
+
}
|
|
498
|
+
return { count: files.length, sha256: h.digest("hex") };
|
|
499
|
+
}
|
|
500
|
+
export const packageDigest = (root) => filesDigest(root, packageFiles(root));
|
|
501
|
+
export const treeDigest = (dir) => filesDigest(dir, treeFiles(dir));
|
|
502
|
+
|
|
503
|
+
// The registration directory, written whole and atomically: the payload and
|
|
504
|
+
// the manifest are staged beside the target and renamed into place, so a
|
|
505
|
+
// reader never sees a half-copied directory under our name (install spec
|
|
506
|
+
// contract 4′). An existing directory is removed only after the stage is
|
|
507
|
+
// complete — and only by the caller's proof that it is ours.
|
|
508
|
+
export function writeOwnTree(dir, { from, subdir, manifestRel, manifest, home = homedir() }) {
|
|
509
|
+
const norm = (s) => String(s || "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
510
|
+
if (!norm(dir).startsWith(norm(claudeHome(home)) + "/")) throw new Error(`writeOwnTree: ${dir} is not under the harness home`);
|
|
511
|
+
const stage = dir + ".staging";
|
|
512
|
+
rmSync(stage, { recursive: true, force: true });
|
|
513
|
+
mkdirSync(join(stage, subdir), { recursive: true });
|
|
514
|
+
const files = copyPackageTree(from, join(stage, subdir));
|
|
515
|
+
mkdirSync(dirname(join(stage, manifestRel)), { recursive: true });
|
|
516
|
+
writeFileSync(join(stage, manifestRel), JSON.stringify(manifest, null, 2) + "\n");
|
|
517
|
+
rmSync(dir, { recursive: true, force: true });
|
|
518
|
+
renameSync(stage, dir);
|
|
519
|
+
return files;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Atomically replace a portable marketplace source with a complete immutable
|
|
523
|
+
// plugin payload. `files` was enumerated and digested during planning; every
|
|
524
|
+
// entry is re-checked as a regular file at apply time, so a symlink swap cannot
|
|
525
|
+
// escape the source root. The previous directory is retained until the caller
|
|
526
|
+
// verifies the host cache and returns the backup path for commit/rollback.
|
|
527
|
+
export function stagePortableMarketplace(dir, { from, files, subdir, catalogRel, catalog, ownershipRel, ownership, homeBase, token = `${process.pid}-${randomUUID()}` }) {
|
|
528
|
+
const norm = (s) => String(s || "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
529
|
+
if (!norm(dir).startsWith(norm(homeBase) + "/")) throw new Error(`stagePortableMarketplace: ${dir} is not under ${homeBase}`);
|
|
530
|
+
const stage = `${dir}.staging-${token}`;
|
|
531
|
+
const backup = existsSync(dir) ? `${dir}.previous-${token}` : null;
|
|
532
|
+
rmSync(stage, { recursive: true, force: true });
|
|
533
|
+
for (const rel of files) {
|
|
534
|
+
const src = join(from, rel);
|
|
535
|
+
const st = lstatSync(src);
|
|
536
|
+
if (!st.isFile() || st.isSymbolicLink()) throw new Error(`portable payload changed under the plan: ${rel} is not a regular file`);
|
|
537
|
+
const dst = join(stage, subdir, rel);
|
|
538
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
539
|
+
cpSync(src, dst);
|
|
540
|
+
}
|
|
541
|
+
mkdirSync(dirname(join(stage, catalogRel)), { recursive: true });
|
|
542
|
+
mkdirSync(dirname(join(stage, ownershipRel)), { recursive: true });
|
|
543
|
+
writeFileAtomic(join(stage, catalogRel), JSON.stringify(catalog, null, 2) + "\n", { sweep: false });
|
|
544
|
+
writeFileAtomic(join(stage, ownershipRel), JSON.stringify(ownership, null, 2) + "\n", { sweep: false });
|
|
545
|
+
if (backup) renameSync(dir, backup);
|
|
546
|
+
try { renameSync(stage, dir); }
|
|
547
|
+
catch (e) { if (backup && !existsSync(dir)) renameSync(backup, dir); throw e; }
|
|
548
|
+
return { stage, backup };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export function finishPortableMarketplace(dir, backup = null) {
|
|
552
|
+
if (backup) rmSync(backup, { recursive: true, force: true });
|
|
553
|
+
return dir;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
export function rollbackPortableMarketplace(dir, backup = null) {
|
|
557
|
+
rmSync(dir, { recursive: true, force: true });
|
|
558
|
+
if (backup && existsSync(backup)) renameSync(backup, dir);
|
|
559
|
+
return dir;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
export function removeTreeUnder(dir, homeBase) {
|
|
563
|
+
const norm = (s) => String(s || "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
564
|
+
if (!norm(dir).startsWith(norm(homeBase) + "/")) throw new Error(`removeTreeUnder: ${dir} is not under ${homeBase}`);
|
|
565
|
+
rmSync(dir, { recursive: true, force: true });
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
export function installedPluginRoot(home = homedir(), preferFamily = null) {
|
|
569
|
+
try {
|
|
570
|
+
const found = installedPluginEntries(home)
|
|
571
|
+
.filter((e) => e.present && e.enabled !== false)
|
|
572
|
+
.map((e) => ({ path: e.path, version: e.version, same: preferFamily && dirname(e.path) === preferFamily ? 1 : 0, at: e.at }));
|
|
573
|
+
// Family is a filter, not a tiebreak: when the caller came from a known
|
|
574
|
+
// marketplace, an install from a DIFFERENT one is not a newer copy of the
|
|
575
|
+
// same plugin — it is someone else's fork, and we do not execute it.
|
|
576
|
+
const family = preferFamily ? found.filter((f) => f.same) : [];
|
|
577
|
+
const pool = family.length ? family : preferFamily ? [] : found;
|
|
578
|
+
pool.sort((a, b) => b.at - a.at || cmpVersion(b.version, a.version));
|
|
579
|
+
return pool.length ? { path: pool[0].path, version: pool[0].version } : null;
|
|
580
|
+
} catch {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// The path of `root` below the host's plugin cache, or null when it is not
|
|
586
|
+
// there. Compared as given first, then as real paths on both sides: Node
|
|
587
|
+
// resolves an entry script's real path, so a terminal run from a cache under a
|
|
588
|
+
// symlinked home (dotfiles) sees the real path where the session's variable
|
|
589
|
+
// names the link, and the two must classify alike — otherwise the terminal run
|
|
590
|
+
// took its own copy for a checkout and planned the package's registration
|
|
591
|
+
// (measured 2026-10-03, the critic of the layout spec's contract 12 amendment).
|
|
592
|
+
function underPluginCache(root, home = homedir()) {
|
|
593
|
+
const norm = (s) => String(s || "").replace(/\\/g, "/").replace(/\/+$/, "");
|
|
594
|
+
const cache = join(claudeHome(home), "plugins", "cache");
|
|
595
|
+
const below = (r, c) => { const a = norm(r), b = norm(c); return a.startsWith(b + "/") ? a.slice(b.length + 1) : null; };
|
|
596
|
+
const direct = below(root, cache);
|
|
597
|
+
if (direct !== null || !root) return direct;
|
|
598
|
+
const real = (x) => { try { return realpathSync(x); } catch { return null; } };
|
|
599
|
+
const rr = real(root), rc = real(cache);
|
|
600
|
+
return rr && rc ? below(rr, rc) : null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Is this plugin root a versioned cache install (the only kind that goes stale)?
|
|
604
|
+
export function isPluginCacheRoot(root, home = homedir()) {
|
|
605
|
+
return underPluginCache(root, home) !== null;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// npx extracts into a cache under _npx/, npm install into node_modules/: both
|
|
609
|
+
// are the package manager's to remove.
|
|
610
|
+
export function isEphemeralRoot(root) {
|
|
611
|
+
return /[\\/](_npx|node_modules)[\\/]/.test(String(root || ""));
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Which channel the copy at `root` came through. A remedy that re-runs the
|
|
615
|
+
// installer has to ask this first, because the package's shell registers the
|
|
616
|
+
// plugin through its OWN channel: run for a git-marketplace user, it adds a
|
|
617
|
+
// second copy and turns the installed one off for the checkout (the layout
|
|
618
|
+
// spec, contract 12 as amended 2026-10-03). The answers:
|
|
619
|
+
// - "registration": the host's cache copy of the registration marketplace the
|
|
620
|
+
// manifest names, i.e. the package's own channel;
|
|
621
|
+
// - "marketplace": any other host cache copy (the git marketplace, a fork);
|
|
622
|
+
// - "package": a package manager's root (npx, node_modules);
|
|
623
|
+
// - "checkout": anything else (a dev checkout, --plugin-dir).
|
|
624
|
+
// String tests, plus at most two realpath calls when a root does not match as
|
|
625
|
+
// given (a symlinked home); no file is read, so the SessionStart budget is
|
|
626
|
+
// untouched, and the marketplace name comes from the manifest, never from here.
|
|
627
|
+
export function installChannel(root, { home = homedir(), harness = sourceHarness() } = {}) {
|
|
628
|
+
if (isEphemeralRoot(root)) return "package";
|
|
629
|
+
const rel = underPluginCache(root, home);
|
|
630
|
+
if (rel === null) return "checkout";
|
|
631
|
+
const marketplace = rel.split("/")[0];
|
|
632
|
+
const own = Object.values(harness?.surfaces || {}).find((x) => x && x.kind === "registration" && x.marketplace_name);
|
|
633
|
+
return own && marketplace === own.marketplace_name ? "registration" : "marketplace";
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ─── Status line wiring (SessionStart-managed) ─────────────────────────
|
|
637
|
+
//
|
|
638
|
+
// The Claude Code statusLine slot is single and NOT plugin-declarable, so
|
|
639
|
+
// when a bound project opts in (projectstore.json → statusline.enabled=true)
|
|
640
|
+
// the SessionStart hook keeps <project>/.claude/settings.local.json pointing
|
|
641
|
+
// at our renderer.
|
|
642
|
+
//
|
|
643
|
+
// It points at a LAUNCHER, not at the plugin script directly. A cache install
|
|
644
|
+
// lives under a versioned path, and the session reads statusLine once at
|
|
645
|
+
// startup — so a direct path always rendered the version installed at the
|
|
646
|
+
// PREVIOUS session start, one restart behind every update. The launcher path
|
|
647
|
+
// never changes, and it resolves the installed plugin at render time, so
|
|
648
|
+
// `/plugin update` + `/reload-plugins` show up immediately. Dev checkouts and
|
|
649
|
+
// --plugin-dir roots carry no version, so those stay wired directly.
|
|
650
|
+
//
|
|
651
|
+
// Idempotent (writes only when the value changes); never clobbers a foreign
|
|
652
|
+
// statusLine; bails on an unparseable settings file. Returns a status string,
|
|
653
|
+
// never throws — the caller wraps it, and this must not break session start.
|
|
654
|
+
|
|
655
|
+
// Where the launcher is WRITTEN: under the harness's state directory (layout
|
|
656
|
+
// ADR decision 4). Readers that ask "is this path ours?" accept the legacy
|
|
657
|
+
// .claude/.projectstore/statusline.mjs too — statusLineIsOurs, statusLineIsOurWiring.
|
|
658
|
+
export function statusLineLauncherPath(projectDir, harnessId = activeHarnessId()) {
|
|
659
|
+
return layoutPaths(projectDir).launcher(harnessId);
|
|
660
|
+
}
|
|
661
|
+
export function legacyStatusLineLauncherPath(projectDir) {
|
|
662
|
+
return layoutPaths(projectDir).legacy.launcher;
|
|
663
|
+
}
|
|
664
|
+
// Both launcher shapes — the new state/<harness>/ one and the legacy one —
|
|
665
|
+
// in one place; the recognisers below and doctor read it.
|
|
666
|
+
export const LAUNCHER_PATH_RE = new RegExp(`${LAYOUT.root.replace(".", "\\.")}/(${LAYOUT.state}/[^/]+/)?${LAYOUT.launcher.replace(".", "\\.")}$`);
|
|
667
|
+
export function isLauncherPath(p) {
|
|
668
|
+
return LAUNCHER_PATH_RE.test(String(p || "").replace(/\\/g, "/"));
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Loose shape test — "could this command be a projectstore renderer?". Used
|
|
672
|
+
// where over-matching is the safe direction: never compose a status line over
|
|
673
|
+
// something that might be us (that would recurse). Both launcher shapes count.
|
|
674
|
+
export function statusLineIsOurs(cmd) {
|
|
675
|
+
if (typeof cmd !== "string") return false;
|
|
676
|
+
const c = cmd.replace(/\\/g, "/");
|
|
677
|
+
return c.includes("scripts/statusline.mjs") || isLauncherPath(c.match(/\S*statusline\.mjs/)?.[0] || c);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export function statusLineScriptPath(cmd) {
|
|
681
|
+
if (typeof cmd !== "string") return null;
|
|
682
|
+
const m = cmd.match(/"([^"]+statusline\.mjs)"/) || cmd.match(/(\S+statusline\.mjs)/);
|
|
683
|
+
return m ? m[1].replace(/\\/g, "/") : null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Strict test — "did WE write this?". Required wherever we would overwrite or
|
|
687
|
+
// delete the entry: the loose shape above also matches a user's own
|
|
688
|
+
// ~/.claude/scripts/statusline.mjs, and clobbering that would take their HUD.
|
|
689
|
+
// Ours means one of: this project's launcher, the running plugin's own script
|
|
690
|
+
// (dev checkouts included), or any versioned install under the plugin cache.
|
|
691
|
+
export function statusLineIsOurWiring(cmd, projectDir, home = homedir(), root = pluginRoot()) {
|
|
692
|
+
const p = statusLineScriptPath(cmd);
|
|
693
|
+
if (!p) return false;
|
|
694
|
+
const norm = (s) => String(s).replace(/\\/g, "/");
|
|
695
|
+
// Ours if it is any harness's launcher in this project's state, or the legacy launcher.
|
|
696
|
+
const lp = layoutPaths(projectDir);
|
|
697
|
+
if (p.startsWith(norm(lp.state) + "/") && isLauncherPath(p)) return true;
|
|
698
|
+
if (p === norm(lp.legacy.launcher)) return true;
|
|
699
|
+
if (p === norm(join(root, "scripts", "statusline.mjs"))) return true;
|
|
700
|
+
return isPluginCacheRoot(dirname(dirname(p)), home);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// The launcher template runs standalone, before it knows which plugin root to
|
|
704
|
+
// load, so it cannot import harness.mjs; the branded names it needs are
|
|
705
|
+
// substituted here instead, from the manifest. All three placeholders must be
|
|
706
|
+
// present or the template is not one we know how to fill. Pure — the test
|
|
707
|
+
// renders through the same function the installer does.
|
|
708
|
+
// `projectDir` is the fourth substitution (2026-09-06): the launcher used to
|
|
709
|
+
// find its project by walking two levels up from its own path, which the move
|
|
710
|
+
// into state/<harness>/ made wrong; it is named instead, as plan() resolved
|
|
711
|
+
// it — the same string the provenance line records, so an npx render and a
|
|
712
|
+
// cache-install render stay byte-identical.
|
|
713
|
+
export function renderStatusLineLauncher(tpl, root, projectDir = projectRoot(), env = process.env) {
|
|
714
|
+
const names = runtimeEnvNames(env);
|
|
715
|
+
const subs = [
|
|
716
|
+
['"__PROJECTSTORE_ROOT__"', JSON.stringify(root)],
|
|
717
|
+
['"__PROJECTSTORE_HOME_ENV__"', JSON.stringify(names.home || "")],
|
|
718
|
+
['"__PROJECTSTORE_PLUGIN_ROOT_ENV__"', JSON.stringify(names.pluginRoot || "")],
|
|
719
|
+
['"__PROJECTSTORE_PROJECT__"', JSON.stringify(projectDir)],
|
|
720
|
+
];
|
|
721
|
+
let src = String(tpl);
|
|
722
|
+
for (const [ph, val] of subs) {
|
|
723
|
+
if (!src.includes(ph)) return null;
|
|
724
|
+
src = src.replace(ph, val);
|
|
725
|
+
}
|
|
726
|
+
return src;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Materialise the launcher into the project, substituting the fallback root
|
|
730
|
+
// and the harness's variable names. Idempotent. Returns its path, or null when
|
|
731
|
+
// the template is unreadable — the caller then wires the plugin script
|
|
732
|
+
// directly, i.e. the old behaviour.
|
|
733
|
+
export function writeStatusLineLauncher(projectDir, root) {
|
|
734
|
+
try {
|
|
735
|
+
const tpl = readFileSync(join(pluginRoot(), "scripts", "statusline-launcher.mjs"), "utf8");
|
|
736
|
+
const src = renderStatusLineLauncher(tpl, root, projectDir);
|
|
737
|
+
if (src === null) return null;
|
|
738
|
+
const p = statusLineLauncherPath(projectDir);
|
|
739
|
+
let cur = null;
|
|
740
|
+
try { cur = readFileSync(p, "utf8"); } catch {}
|
|
741
|
+
if (cur !== src) {
|
|
742
|
+
ensureStateDir(projectDir); // carries the nested .gitignore: this path is machine-specific
|
|
743
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
744
|
+
// sweep=false: this runs on session paths, not --write — keep it cheap.
|
|
745
|
+
writeFileAtomic(p, src, { sweep: false });
|
|
746
|
+
}
|
|
747
|
+
return p;
|
|
748
|
+
} catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// The command our entry should carry for this installation: the version-free
|
|
754
|
+
// launcher for a marketplace-cache install, the plugin's own script for a dev
|
|
755
|
+
// checkout (its path has no version to go stale). One resolver, shared by the
|
|
756
|
+
// SessionStart refresh and the installer, so the two cannot disagree.
|
|
757
|
+
export function desiredStatusLineCommand(projectDir, root = pluginRoot(), home = homedir()) {
|
|
758
|
+
const launcher = isPluginCacheRoot(root, home);
|
|
759
|
+
return {
|
|
760
|
+
launcher,
|
|
761
|
+
command: launcher ? `node "${statusLineLauncherPath(projectDir)}"` : `node "${join(root, "scripts", "statusline.mjs")}"`,
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// REFRESH ONLY. This runs on every SessionStart, without a gate, so it may
|
|
766
|
+
// keep an entry that is already ours pointing at a working renderer and remove
|
|
767
|
+
// our entry on disable — but it never creates: the launcher is an exclusive,
|
|
768
|
+
// provenance-stamped file, and stamping lives in provenance.mjs, which stays
|
|
769
|
+
// out of the SessionStart module graph by the install spec's own rule. First
|
|
770
|
+
// wiring is install-harness.mjs's, behind its preview and confirmation; the
|
|
771
|
+
// commands that enable the status line invoke it. An enabled flag with no
|
|
772
|
+
// entry reports "needs-install".
|
|
773
|
+
export function syncStatusLine(cfg, projectDir, home = homedir()) {
|
|
774
|
+
const st = cfg && cfg.statusline;
|
|
775
|
+
if (!st || typeof st.enabled !== "boolean") return "no-flag"; // absent → leave manual installs alone
|
|
776
|
+
|
|
777
|
+
const p = hostSettingsPath(projectDir);
|
|
778
|
+
const root = pluginRoot();
|
|
779
|
+
|
|
780
|
+
let settings = {};
|
|
781
|
+
if (existsSync(p)) {
|
|
782
|
+
try {
|
|
783
|
+
settings = JSON.parse(readFileSync(p, "utf8"));
|
|
784
|
+
} catch {
|
|
785
|
+
return "skipped-unparseable"; // never clobber a file we cannot read
|
|
786
|
+
}
|
|
787
|
+
if (!settings || typeof settings !== "object" || Array.isArray(settings)) {
|
|
788
|
+
return "skipped-nonobject";
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
const cur = settings.statusLine;
|
|
793
|
+
const curCmd = cur && typeof cur.command === "string" ? cur.command : null;
|
|
794
|
+
const isOurs = statusLineIsOurWiring(curCmd, projectDir, home, root);
|
|
795
|
+
|
|
796
|
+
let changed = false;
|
|
797
|
+
if (st.enabled) {
|
|
798
|
+
// Any existing non-ours entry: leave the slot to its owner — and write
|
|
799
|
+
// nothing into the project, since we are not wiring anything here.
|
|
800
|
+
if (cur && !isOurs) return "foreign-present";
|
|
801
|
+
if (!cur) return "needs-install";
|
|
802
|
+
// Refresh: the launcher when it is on disk, else the plugin's own script,
|
|
803
|
+
// so an entry always names a renderer that exists. A missing launcher is
|
|
804
|
+
// install's to create (stamped), never this path's.
|
|
805
|
+
const { launcher } = desiredStatusLineCommand(projectDir, root, home);
|
|
806
|
+
// A launcher on disk at either path — the new one, or the legacy one an
|
|
807
|
+
// earlier release wrote (the layout ADR) — is kept; moving it is install's.
|
|
808
|
+
const onDisk = [statusLineLauncherPath(projectDir), legacyStatusLineLauncherPath(projectDir)].find((f) => existsSync(f));
|
|
809
|
+
const desired = launcher && onDisk
|
|
810
|
+
? `node "${onDisk}"`
|
|
811
|
+
: `node "${join(root, "scripts", "statusline.mjs")}"`;
|
|
812
|
+
if (curCmd !== desired) {
|
|
813
|
+
// Keep any sibling keys the platform supports on this object
|
|
814
|
+
// (refreshInterval and friends) — we own the command, not the entry.
|
|
815
|
+
settings.statusLine = { ...(cur && typeof cur === "object" ? cur : {}), type: "command", command: desired };
|
|
816
|
+
changed = true;
|
|
817
|
+
}
|
|
818
|
+
} else if (isOurs) {
|
|
819
|
+
delete settings.statusLine; // disabled: remove only our entry, keep the rest
|
|
820
|
+
// …and the launcher, but only one we wrote: a foreign file at our path is
|
|
821
|
+
// refused by every verb (contract 5), and refresh is a verb. The recogniser
|
|
822
|
+
// is the template's own header line — provenance.mjs stays out of this
|
|
823
|
+
// module graph.
|
|
824
|
+
try {
|
|
825
|
+
const lp = statusLineLauncherPath(projectDir);
|
|
826
|
+
const text = readFileSync(lp, "utf8");
|
|
827
|
+
if (text.includes(LAUNCHER_HEADER)) unlinkSync(lp);
|
|
828
|
+
} catch {}
|
|
829
|
+
changed = true;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
if (!changed) return "unchanged";
|
|
833
|
+
try {
|
|
834
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
835
|
+
writeFileSync(p, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
|
836
|
+
} catch {
|
|
837
|
+
return "write-failed";
|
|
838
|
+
}
|
|
839
|
+
return st.enabled ? "enabled" : "disabled";
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// ─── ADR-002 agents block ──────────────────────────────────────────────
|
|
843
|
+
//
|
|
844
|
+
// The managed routing block in CLAUDE.md / AGENTS.md. One parser, used by
|
|
845
|
+
// doctor (to report) and by install-harness.mjs (to write); the version lives
|
|
846
|
+
// in the template, never in a constant, so a bump cannot land in one place
|
|
847
|
+
// and not the other.
|
|
848
|
+
|
|
849
|
+
// The launcher template's own header line — the recogniser for a launcher we
|
|
850
|
+
// wrote before provenance existed (install spec contract 4, rung 1″) and for
|
|
851
|
+
// the one file the disable path may unlink. One literal, three readers
|
|
852
|
+
// (surfaces.mjs, doctor.mjs, syncStatusLine).
|
|
853
|
+
export const LAUNCHER_HEADER = "projectstore — status line launcher";
|
|
854
|
+
|
|
855
|
+
export const AGENTS_BLOCK_OPEN_SRC = String.raw`<!--\s*projectstore:agents v(\d+)[^\n]*?-->`;
|
|
856
|
+
// The loose form: an open marker a model re-wrapped so `-->` fell to the next
|
|
857
|
+
// line. Anchored like the strict form, so prose that merely mentions the
|
|
858
|
+
// marker inside another comment is not a block; it cannot match the close
|
|
859
|
+
// marker (no " v<digit>"). Detected so a
|
|
860
|
+
// wrapped block is refused as unparseable rather than read as absent — the
|
|
861
|
+
// strict parser reading "absent" is how install appends a second block and
|
|
862
|
+
// uninstall reports "nothing to remove" over a block that is there.
|
|
863
|
+
export const AGENTS_BLOCK_OPEN_LOOSE_SRC = String.raw`<!--\s*projectstore:agents v(\d+)`;
|
|
864
|
+
export const AGENTS_BLOCK_CLOSE = "<!-- /projectstore:agents -->";
|
|
865
|
+
|
|
866
|
+
export function agentsBlockTemplatePath(root = pluginRoot()) {
|
|
867
|
+
return join(root, "templates", "claude-md-block.md.tmpl");
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export function agentsBlockVersion(tmpl = null) {
|
|
871
|
+
const text = tmpl ?? readFileSync(agentsBlockTemplatePath(), "utf8");
|
|
872
|
+
const m = new RegExp(AGENTS_BLOCK_OPEN_SRC).exec(text);
|
|
873
|
+
return m ? Number(m[1]) : null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// The first block in `text`: its version, its span, whether it closes, and how
|
|
877
|
+
// many open markers the file carries (more than one is a duplicate).
|
|
878
|
+
export function findAgentsBlock(text) {
|
|
879
|
+
if (typeof text !== "string") return null;
|
|
880
|
+
// Count with the loose form, so one good block plus one wrapped marker is
|
|
881
|
+
// "more than once" (refused), never "one block" (half-edited).
|
|
882
|
+
const count = [...text.matchAll(new RegExp(AGENTS_BLOCK_OPEN_LOOSE_SRC, "g"))].length;
|
|
883
|
+
const m = new RegExp(AGENTS_BLOCK_OPEN_SRC).exec(text);
|
|
884
|
+
if (!m) {
|
|
885
|
+
const loose = new RegExp(AGENTS_BLOCK_OPEN_LOOSE_SRC).exec(text);
|
|
886
|
+
if (!loose) return null;
|
|
887
|
+
const line = text.slice(0, loose.index).split("\n").length;
|
|
888
|
+
return { present: true, v: Number(loose[1]), start: loose.index, end: null, unclosed: true, wrapped: true, line, count, block: null };
|
|
889
|
+
}
|
|
890
|
+
const closeAt = text.indexOf(AGENTS_BLOCK_CLOSE, m.index + m[0].length);
|
|
891
|
+
if (closeAt === -1) return { present: true, v: Number(m[1]), start: m.index, end: null, unclosed: true, count, block: null };
|
|
892
|
+
const end = closeAt + AGENTS_BLOCK_CLOSE.length;
|
|
893
|
+
return { present: true, v: Number(m[1]), start: m.index, end, unclosed: false, count, block: text.slice(m.index, end) };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Whether `text` holds `line` as a line of its own, trimmed and exact — the
|
|
897
|
+
// installer's match for the `@<file>` import, so `@./AGENTS.md` reads as
|
|
898
|
+
// absent to install and doctor alike.
|
|
899
|
+
export function importsLine(text, line) {
|
|
900
|
+
return String(text ?? "").split("\n").some((l) => l.trim() === line);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Whether a harness sees the one agents block where it stands — the predicate
|
|
904
|
+
// doctor reports by (the install spec, contract 6 as amended after the rc.3
|
|
905
|
+
// tag). Install's placement rules agree with it: it moves a block whose file
|
|
906
|
+
// is not among the harness's files and bridges one that is, through the same
|
|
907
|
+
// import match (importsLine); a test holds the two to one answer. Visible when
|
|
908
|
+
// the block's file is among the harness's agents_block.files and is either the
|
|
909
|
+
// file it reads by itself or imported from that file. A harness with no agents
|
|
910
|
+
// block has nothing to see it with. `texts` maps a file name to its text; an
|
|
911
|
+
// absent file is undefined.
|
|
912
|
+
export function blockVisibleTo(manifest, file, texts = {}) {
|
|
913
|
+
const ab = manifest?.surfaces?.agents_block;
|
|
914
|
+
if (!ab || ab.supported === false) return true;
|
|
915
|
+
if (!(ab.files || []).includes(file)) return false;
|
|
916
|
+
const native = ab.reads_natively;
|
|
917
|
+
return !native || native === file || importsLine(texts[native], `@${file}`);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Replace the block in place, else append it after the user's own content.
|
|
921
|
+
export function replaceAgentsBlock(text, block) {
|
|
922
|
+
const base = String(text ?? "");
|
|
923
|
+
const f = findAgentsBlock(base);
|
|
924
|
+
if (f && !f.unclosed) return base.slice(0, f.start) + block + base.slice(f.end);
|
|
925
|
+
if (!base.trim()) return block + "\n";
|
|
926
|
+
return base.replace(/\s*$/, "") + "\n\n" + block + "\n";
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Remove the block and the blank line that separated it; everything else is
|
|
930
|
+
// the user's and stays byte-identical.
|
|
931
|
+
export function removeAgentsBlock(text) {
|
|
932
|
+
const base = String(text ?? "");
|
|
933
|
+
const f = findAgentsBlock(base);
|
|
934
|
+
if (!f || f.unclosed) return base;
|
|
935
|
+
const before = base.slice(0, f.start).replace(/\n+$/, "\n");
|
|
936
|
+
const rest = base.slice(f.end);
|
|
937
|
+
const after = rest.trim() ? rest.replace(/^\n+/, "\n") : "";
|
|
938
|
+
const out = before + after;
|
|
939
|
+
return out.trim() ? out.replace(/^\n+/, "") : "";
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// The block body for a project: the template with agent bullets kept only
|
|
943
|
+
// for agents in the layout's roster. Bullets that name no agent — the entry
|
|
944
|
+
// rule, the instruction-conflict clause, model resolution, vault
|
|
945
|
+
// communication — always stay (ADR-002). No roster: the template verbatim.
|
|
946
|
+
export function renderAgentsBlock(tmpl, roster = null) {
|
|
947
|
+
const names = roster ? new Set(roster) : null;
|
|
948
|
+
const lines = String(tmpl).replace(/\r\n/g, "\n").replace(/\n+$/, "").split("\n");
|
|
949
|
+
const out = [];
|
|
950
|
+
let bullet = null;
|
|
951
|
+
// An agent line routes to one agent by its shape — "…: run the
|
|
952
|
+
// \`projectstore:critic\` agent", "…: consult \`projectstore:planner\`" —
|
|
953
|
+
// and is dropped when that agent is not in the roster. Every other bullet
|
|
954
|
+
// stays, however many agents it mentions in passing.
|
|
955
|
+
// Matched over the whole bullet with its wrapping collapsed, since the
|
|
956
|
+
// routing verb may sit on the bullet's second physical line.
|
|
957
|
+
const AGENT_LINE = /^- [^:]*?:\s+(?:run|consult) (?:the )?`projectstore:([a-z]+)`/;
|
|
958
|
+
const flush = () => {
|
|
959
|
+
if (!bullet) return;
|
|
960
|
+
const m = AGENT_LINE.exec(bullet.map((l) => l.trim()).join(" ").replace(/^- /, "- "));
|
|
961
|
+
if (!names || !m || names.has(m[1])) out.push(...bullet);
|
|
962
|
+
bullet = null;
|
|
963
|
+
};
|
|
964
|
+
for (const l of lines) {
|
|
965
|
+
if (/^- /.test(l)) { flush(); bullet = [l]; }
|
|
966
|
+
else if (bullet && /^\s+\S/.test(l)) bullet.push(l);
|
|
967
|
+
else { flush(); out.push(l); }
|
|
968
|
+
}
|
|
969
|
+
flush();
|
|
970
|
+
return out.join("\n");
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
// ─── Layouts ───────────────────────────────────────────────────────────
|
|
974
|
+
|
|
975
|
+
export function loadLayout(name, root = pluginRoot()) {
|
|
976
|
+
const p = join(root, "scaffold", "layouts", `${name}.json`);
|
|
977
|
+
if (!existsSync(p)) {
|
|
978
|
+
throw new Error(`Layout not found: ${name} (expected at ${p})`);
|
|
979
|
+
}
|
|
980
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// The layout's agent roster (scaffold/layouts/<layout>.json → agents), or null
|
|
984
|
+
// when no binding names a layout that loads — a caller then validates nothing
|
|
985
|
+
// against it rather than refusing every name.
|
|
986
|
+
export function layoutRoster(cfg, root = pluginRoot()) {
|
|
987
|
+
if (!cfg || typeof cfg.layout !== "string") return null;
|
|
988
|
+
try {
|
|
989
|
+
const r = loadLayout(cfg.layout, root).agents;
|
|
990
|
+
return Array.isArray(r) && r.length ? r : null;
|
|
991
|
+
} catch { return null; }
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
export function folderByKind(layout, kind) {
|
|
995
|
+
return layout.folders.find((f) => f.kind === kind) || null;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
// ─── Templates ─────────────────────────────────────────────────────────
|
|
999
|
+
|
|
1000
|
+
export function loadTemplate(lang, name) {
|
|
1001
|
+
const p = join(pluginRoot(), "templates", lang, `${name}.md.tmpl`);
|
|
1002
|
+
if (!existsSync(p)) {
|
|
1003
|
+
throw new Error(`Template not found: templates/${lang}/${name}.md.tmpl`);
|
|
1004
|
+
}
|
|
1005
|
+
return readFileSync(p, "utf8");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// {{x}} substitutes raw; {{x_json}} substitutes JSON.stringify(String(x)) — a
|
|
1009
|
+
// valid YAML double-quoted scalar. Frontmatter lines in templates use the
|
|
1010
|
+
// _json form so titles containing `"` or `:` cannot corrupt the YAML.
|
|
1011
|
+
export function renderTemplate(template, vars) {
|
|
1012
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
1013
|
+
if (key.endsWith("_json")) {
|
|
1014
|
+
const base = key.slice(0, -5);
|
|
1015
|
+
return base in vars ? JSON.stringify(String(vars[base])) : '""';
|
|
1016
|
+
}
|
|
1017
|
+
if (key in vars) {
|
|
1018
|
+
const v = vars[key];
|
|
1019
|
+
if (Array.isArray(v)) return JSON.stringify(v);
|
|
1020
|
+
return String(v);
|
|
1021
|
+
}
|
|
1022
|
+
return "";
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ─── Heading / keyword registry (PS-SPEC story-002) ────────────────────
|
|
1027
|
+
//
|
|
1028
|
+
// scaffold/headings.json is the language-independent registry of the section
|
|
1029
|
+
// headings, inline keywords and index-table column names the deterministic
|
|
1030
|
+
// scripts (doctor / reconcile / story-section) must recognize. Per id, per
|
|
1031
|
+
// language, an ARRAY of accepted forms; the FIRST form of the configured
|
|
1032
|
+
// language is the canonical form used when WRITING. Matching always accepts
|
|
1033
|
+
// every registered form of every language — a ru-headed file in an en-bound
|
|
1034
|
+
// vault must still lint. This is deliberately separate from
|
|
1035
|
+
// templates/<lang>/strings.json, which is a render-only map for the statusline.
|
|
1036
|
+
|
|
1037
|
+
let _headingsCache = null;
|
|
1038
|
+
|
|
1039
|
+
export function loadHeadingsRegistry() {
|
|
1040
|
+
if (_headingsCache) return _headingsCache;
|
|
1041
|
+
const p = join(pluginRoot(), "scaffold", "headings.json");
|
|
1042
|
+
try {
|
|
1043
|
+
_headingsCache = JSON.parse(readFileSync(p, "utf8"));
|
|
1044
|
+
} catch (e) {
|
|
1045
|
+
throw new Error(`heading registry missing or unreadable (${p}): ${e.message}`);
|
|
1046
|
+
}
|
|
1047
|
+
return _headingsCache;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function escapeRe(s) {
|
|
1051
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
function allForms(section, id) {
|
|
1055
|
+
const entry = loadHeadingsRegistry()[section]?.[id];
|
|
1056
|
+
if (!entry) throw new Error(`headings.json has no ${section} entry "${id}"`);
|
|
1057
|
+
return Object.values(entry).flat();
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// Canonical write form for the configured language (en fallback).
|
|
1061
|
+
export function heading(id, lang = "en") {
|
|
1062
|
+
const entry = loadHeadingsRegistry().headings?.[id];
|
|
1063
|
+
if (!entry) throw new Error(`headings.json has no headings entry "${id}"`);
|
|
1064
|
+
return (entry[lang] || entry.en)[0];
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// Matches a `## <heading>` line in any registered language, case-insensitively
|
|
1068
|
+
// (hand-typed `## критерии приёмки` still matches). Anchored to the full line
|
|
1069
|
+
// so "Acceptance" never matches "Acceptance Criteria".
|
|
1070
|
+
export function headingLineRe(id) {
|
|
1071
|
+
const forms = allForms("headings", id).map(escapeRe);
|
|
1072
|
+
return new RegExp(`^##\\s+(?:${forms.join("|")})\\s*$`, "mi");
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
// Extract the body of section `id`: text between its heading line and the
|
|
1076
|
+
// next `## ` heading (or end of file). Returns null when the section is absent.
|
|
1077
|
+
export function sectionOf(body, id) {
|
|
1078
|
+
const m = body.match(headingLineRe(id));
|
|
1079
|
+
if (!m) return null;
|
|
1080
|
+
const rest = body.slice(m.index + m[0].length);
|
|
1081
|
+
const next = rest.search(/^## /m);
|
|
1082
|
+
return next === -1 ? rest : rest.slice(0, next);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
export function keywordRe(id) {
|
|
1086
|
+
const forms = allForms("keywords", id).map(escapeRe);
|
|
1087
|
+
return new RegExp(`(?:${forms.join("|")})`, "i");
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// The two inline grammars built from a keyword plus a colon: the evidence suffix
|
|
1091
|
+
// on a checked acceptance criterion, and the story attribution on a spec
|
|
1092
|
+
// acceptance item. Both live here rather than inline at their call sites so the
|
|
1093
|
+
// gate and its tests cannot drift, and both accept the CJK-width colon — a zh
|
|
1094
|
+
// vault writes `— 证据:<test>` and `— stories:PS-X/story-foo`, and a full-width
|
|
1095
|
+
// colon must not read as the marker being absent.
|
|
1096
|
+
export function evidenceSuffixRe() {
|
|
1097
|
+
return new RegExp(`[—–-]\\s*${keywordRe("evidence").source}\\s*[::]`, "i");
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
export function storiesAttributionRe() {
|
|
1101
|
+
return new RegExp(`[—–-]\\s*${keywordRe("stories").source}\\s*[::]\\s*(.+)$`, "i");
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// The body footer (`*Last updated: 2026-01-01*`) is content rather than a heading,
|
|
1105
|
+
// but the lifecycle gates keep it in step with frontmatter `updated:`. Matching
|
|
1106
|
+
// accepts every registered language; the rewrite preserves the file's OWN prefix
|
|
1107
|
+
// verbatim through capture group 1, so a locale's punctuation convention — fr
|
|
1108
|
+
// writes `… : `, zh writes `…:` — survives without the writer needing to know
|
|
1109
|
+
// which locale it is looking at, and a hand-mixed vault keeps each file's form.
|
|
1110
|
+
export function footerDateRe() {
|
|
1111
|
+
const forms = allForms("footers", "last_updated").map(escapeRe);
|
|
1112
|
+
return new RegExp(`^(\\*(?:${forms.join("|")})\\s*[::]\\s*).*(\\*)$`, "mi");
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// Matches a folder-README index header row in any registered language,
|
|
1116
|
+
// in the standard 4-column form: | File | Title | Status | Date |
|
|
1117
|
+
//
|
|
1118
|
+
// End-anchored on purpose: a header carrying extra hand-added columns
|
|
1119
|
+
// (`| File | Title | Status | Date | Notes |`) is NOT this table. Without the
|
|
1120
|
+
// anchor it prefix-matched, and rebuildIndexRows then rewrote every managed
|
|
1121
|
+
// row to the registered four columns — silently destroying the extra cells,
|
|
1122
|
+
// which are human-owned content no regeneration can recompute. Unanchored,
|
|
1123
|
+
// doctor's index-header check could not fire either (the header "matched"),
|
|
1124
|
+
// so the loss had no detector at all. Anchored, both halves behave as
|
|
1125
|
+
// documented: reconcile reports the index unusable and doctor warns.
|
|
1126
|
+
export function indexHeaderRe() {
|
|
1127
|
+
const cols = ["file", "title", "status", "date"].map((c) =>
|
|
1128
|
+
allForms("index_columns", c).map(escapeRe).join("|"));
|
|
1129
|
+
return new RegExp(
|
|
1130
|
+
`^\\|\\s*(?:${cols[0]})\\s*\\|\\s*(?:${cols[1]})\\s*\\|\\s*(?:${cols[2]})\\s*\\|\\s*(?:${cols[3]})\\s*\\|\\s*$`);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// ─── Frontmatter list fields (code_refs / specs / stories / adr) ───────
|
|
1134
|
+
//
|
|
1135
|
+
// Frontmatter lists must use inline flow form (`specs: ["SPEC-001"]`) —
|
|
1136
|
+
// parseFrontmatter is line-based and cannot see block sequences. Single
|
|
1137
|
+
// shared parser; doctor emits a dedicated finding for the block-form trap.
|
|
1138
|
+
export function listOf(fm, key) {
|
|
1139
|
+
const raw = fm[key];
|
|
1140
|
+
if (!raw || raw === "[]") return [];
|
|
1141
|
+
if (Array.isArray(raw)) return raw.filter((x) => typeof x === "string");
|
|
1142
|
+
if (typeof raw !== "string") return [];
|
|
1143
|
+
try {
|
|
1144
|
+
const v = JSON.parse(raw);
|
|
1145
|
+
return Array.isArray(v) ? v.filter((x) => typeof x === "string") : [];
|
|
1146
|
+
} catch {
|
|
1147
|
+
return [];
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// ─── Vault-side policy config (ADR-007 Decision 4) ─────────────────────
|
|
1152
|
+
//
|
|
1153
|
+
// <vault>/.projectstore.json — vault ROOT, dot-prefixed: git commits it (so
|
|
1154
|
+
// the policy survives clones and second machines), Obsidian hides it, and it
|
|
1155
|
+
// is intentionally NOT inside <vault>/.projectstore/, whose .gitignore ("*")
|
|
1156
|
+
// would defeat the whole point. Keys: spec_policy ("required"|"optional"),
|
|
1157
|
+
// lifecycle_gates ("on"|"off"), spec_policy_since (ISO-8601, stamped when
|
|
1158
|
+
// spec_policy first becomes "required").
|
|
1159
|
+
export function vaultConfigPath(vault) {
|
|
1160
|
+
return join(vault, LAYOUT.vaultConfig);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
export function readVaultConfig(vault) {
|
|
1164
|
+
const p = vaultConfigPath(vault);
|
|
1165
|
+
if (!existsSync(p)) return {};
|
|
1166
|
+
try {
|
|
1167
|
+
const v = JSON.parse(readFileSync(p, "utf8"));
|
|
1168
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
1169
|
+
} catch {
|
|
1170
|
+
return {};
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
export function writeVaultConfig(vault, cfg) {
|
|
1175
|
+
writeFileSync(vaultConfigPath(vault), JSON.stringify(cfg, null, 2) + "\n", "utf8");
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// Legacy exemption (ADR-007 Decision 6): a story is exempt from spec-first
|
|
1179
|
+
// and lifecycle gates iff it was already done before the policy existed —
|
|
1180
|
+
// status done AND (no closed_at at all, or closed_at earlier than
|
|
1181
|
+
// spec_policy_since). Stories in progress/review at enable time are IN scope.
|
|
1182
|
+
export function isLegacyStory(fm, since) {
|
|
1183
|
+
const status = String(fm.status || "").toLowerCase();
|
|
1184
|
+
if (status !== "done") return false;
|
|
1185
|
+
const closed = fm.closed_at && fm.closed_at !== "null" ? String(fm.closed_at) : null;
|
|
1186
|
+
if (!closed) return true;
|
|
1187
|
+
if (!since) return true;
|
|
1188
|
+
return closed < String(since);
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// ─── Slug / numbering ──────────────────────────────────────────────────
|
|
1192
|
+
|
|
1193
|
+
// Cyrillic → Latin so ru titles produce portable ASCII filenames; every other
|
|
1194
|
+
// Unicode letter/digit survives via \p{L}\p{N}. Never returns an empty slug.
|
|
1195
|
+
const CYRILLIC = {
|
|
1196
|
+
а: "a", б: "b", в: "v", г: "g", д: "d", е: "e", ё: "e", ж: "zh", з: "z",
|
|
1197
|
+
и: "i", й: "y", к: "k", л: "l", м: "m", н: "n", о: "o", п: "p", р: "r",
|
|
1198
|
+
с: "s", т: "t", у: "u", ф: "f", х: "h", ц: "ts", ч: "ch", ш: "sh",
|
|
1199
|
+
щ: "shch", ъ: "", ы: "y", ь: "", э: "e", ю: "yu", я: "ya",
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
export function slugify(s) {
|
|
1203
|
+
const slug = s
|
|
1204
|
+
.toLowerCase()
|
|
1205
|
+
.replace(/[а-яё]/g, (c) => CYRILLIC[c] ?? c)
|
|
1206
|
+
.replace(/[^\p{L}\p{N}\s-]/gu, "")
|
|
1207
|
+
.replace(/\s+/g, "-")
|
|
1208
|
+
.replace(/-+/g, "-")
|
|
1209
|
+
.replace(/^-|-$/g, "");
|
|
1210
|
+
return slug || "untitled";
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// Prefix is matched case-insensitively and with regex metacharacters escaped:
|
|
1214
|
+
// GrammarHelper ships `spec-002-*.md` while the layout prefix is `SPEC-` — a
|
|
1215
|
+
// case-sensitive match would hand out SPEC-001 next to an existing spec-001.
|
|
1216
|
+
export function nextNumber(dir, prefix, pad = 3) {
|
|
1217
|
+
if (!existsSync(dir)) return String(1).padStart(pad, "0");
|
|
1218
|
+
const escaped = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1219
|
+
const rx = new RegExp(`^${escaped}(\\d+)`, "i");
|
|
1220
|
+
const nums = readdirSync(dir)
|
|
1221
|
+
.map((n) => n.match(rx))
|
|
1222
|
+
.filter(Boolean)
|
|
1223
|
+
.map((m) => parseInt(m[1], 10));
|
|
1224
|
+
const next = (nums.length ? Math.max(...nums) : 0) + 1;
|
|
1225
|
+
return String(next).padStart(pad, "0");
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
export function today() {
|
|
1229
|
+
return new Date().toISOString().slice(0, 10);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
// Full ISO-8601 UTC timestamp — for story lifecycle fields (started_at /
|
|
1233
|
+
// closed_at / plan_updated_at) and spec_policy_since, which must be strictly
|
|
1234
|
+
// comparable and need sub-day resolution (diff-refs anchors git --since on
|
|
1235
|
+
// them). Deliberately finer-grained than the date-only created:/updated:.
|
|
1236
|
+
export function nowIso() {
|
|
1237
|
+
return new Date().toISOString();
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
// ─── Artifact identity (ADR-010 / SPEC-002) ────────────────────────────
|
|
1241
|
+
//
|
|
1242
|
+
// Identity lives in the slug, not in an allocated number. Two filename eras
|
|
1243
|
+
// coexist indefinitely (grandfathering — no renames): numbered
|
|
1244
|
+
// `ADR-003-foo.md` / `story-006-foo.md` and slug-only `foo.md` /
|
|
1245
|
+
// `story-foo.md`. Comparison therefore works on CANDIDATE SETS, not single
|
|
1246
|
+
// strings: a numbered-era name contributes both its full stem and its
|
|
1247
|
+
// number-stripped slug, so `ADR-003-foo.md` collides with `foo.md` and
|
|
1248
|
+
// `story-006-foo.md` collides with `story-foo.md`. A digit-leading slug
|
|
1249
|
+
// (`story-2024-review.md`) is formally ambiguous between the eras — it
|
|
1250
|
+
// contributes both readings and is flagged, never silently collapsed to one.
|
|
1251
|
+
|
|
1252
|
+
// Legacy numbered shape: `<PREFIX><digits>` / `<PREFIX><digits>-<slug>` for
|
|
1253
|
+
// prefixed kinds (prefix matched case-insensitively — GrammarHelper ships
|
|
1254
|
+
// lowercase `spec-002-*` against layout prefix `SPEC-`), `story-<digits>` /
|
|
1255
|
+
// `story-<digits>-<slug>` for stories. Any digit count (the legacy pad was a
|
|
1256
|
+
// rendering choice, not an identity fact). Returns { number, slug } or null.
|
|
1257
|
+
export function isLegacyNumberedId(name, { prefix = null, story = false } = {}) {
|
|
1258
|
+
const stem = String(name).replace(/\.md$/i, "");
|
|
1259
|
+
const anchor = story ? "story-" : prefix;
|
|
1260
|
+
if (!anchor) return null;
|
|
1261
|
+
const m = stem.match(new RegExp(`^${escapeRe(anchor)}(\\d+)(?:-(.+))?$`, "i"));
|
|
1262
|
+
return m ? { number: m[1], slug: m[2] ?? null } : null;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// Normalized identity of one filename (or dir name, for folder-shape
|
|
1266
|
+
// stories). `primary` is the as-written reading (story kind marker stripped,
|
|
1267
|
+
// lowercased); `candidates` adds the legacy number-stripped reading when the
|
|
1268
|
+
// name matches a numbered shape. `digitLeading` marks slugs that visually
|
|
1269
|
+
// resemble the numbered era (creation warns on these; overlaps arising only
|
|
1270
|
+
// from that ambiguity report at warn, not issue).
|
|
1271
|
+
export function slugIdentity(name, { prefix = null, story = false } = {}) {
|
|
1272
|
+
const stem = String(name).replace(/\.md$/i, "").toLowerCase();
|
|
1273
|
+
const base = story ? stem.replace(/^story-/, "") : stem;
|
|
1274
|
+
const candidates = [{ id: base, via: "self" }];
|
|
1275
|
+
const legacy = isLegacyNumberedId(stem, { prefix, story });
|
|
1276
|
+
if (legacy?.slug) {
|
|
1277
|
+
const id = legacy.slug.toLowerCase();
|
|
1278
|
+
if (id !== base) candidates.push({ id, via: story ? "story-number" : "prefix-number" });
|
|
1279
|
+
}
|
|
1280
|
+
return {
|
|
1281
|
+
primary: base,
|
|
1282
|
+
candidates,
|
|
1283
|
+
digitLeading: /^\d/.test(base),
|
|
1284
|
+
legacyNumber: legacy ? legacy.number : null,
|
|
1285
|
+
};
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
// The ONE spec↔story matcher (SPEC-002 contract 5) — replaces the inline
|
|
1289
|
+
// predicates in doctor's resolveSpecStory / checkSpecLinks /
|
|
1290
|
+
// checkSpecAcceptance. `entry` is the story part of a spec's qualified
|
|
1291
|
+
// "<epic-id>/<story-id>" reference. Tiered, strongest first; returns the tier
|
|
1292
|
+
// (1 = exact frontmatter id, 2 = exact filename stem, 3 = numbered-era
|
|
1293
|
+
// prefix fallback) or 0. The fallback fires ONLY for legacy-shaped entries
|
|
1294
|
+
// (`story-NNN` / `story-NNN-<slug>`) — a slug entry must match exactly, so
|
|
1295
|
+
// "PS-X/cache" can never mis-attribute to `cache-invalidation.md`.
|
|
1296
|
+
export function storyMatchesEntry(entry, { id = null, stem = "" } = {}) {
|
|
1297
|
+
const e = String(entry);
|
|
1298
|
+
if (id != null && id !== "" && String(id) === e) return 1;
|
|
1299
|
+
if (stem === e) return 2;
|
|
1300
|
+
if (isLegacyNumberedId(e, { story: true }) && stem.startsWith(e + "-")) return 3;
|
|
1301
|
+
return 0;
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
// Sync-conflict blacklist (SPEC-002 contract 7): filename shapes left by
|
|
1305
|
+
// sync engines — `* <n>.md`, `* copy*.md`, `*(<n>).md`. A legal-form
|
|
1306
|
+
// whitelist is deliberately NOT used: it would flag hand-created legacy
|
|
1307
|
+
// notes. Returns null when legal, else a short description for the finding.
|
|
1308
|
+
export function legalArtifactName(name) {
|
|
1309
|
+
if (!/\.md$/i.test(name)) return null;
|
|
1310
|
+
const stem = String(name).replace(/\.md$/i, "");
|
|
1311
|
+
if (/\s\d+$/.test(stem)) return `trailing " <n>" numeral — sync-engine duplicate shape`;
|
|
1312
|
+
if (/\(\d+\)$/.test(stem)) return `trailing "(<n>)" numeral — sync-engine duplicate shape`;
|
|
1313
|
+
if (/(^|\s)copy(\s\d+)?$/i.test(stem) || /conflicted copy/i.test(stem)) {
|
|
1314
|
+
return `"copy" suffix — sync-engine duplicate shape`;
|
|
1315
|
+
}
|
|
1316
|
+
return null;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
// Display number of an artifact: an explicit frontmatter `number:` wins,
|
|
1320
|
+
// else the legacy filename number; null when neither exists — the badge
|
|
1321
|
+
// then simply does not render (SPEC-002 contract 8). Numbers are reference
|
|
1322
|
+
// metadata like a Jira key, not identity (ADR-010).
|
|
1323
|
+
export function displayNumberOf(fm, name, opts = {}) {
|
|
1324
|
+
const n = fm && fm.number != null ? String(fm.number).trim() : "";
|
|
1325
|
+
if (n && n !== "null") return n;
|
|
1326
|
+
return slugIdentity(name, opts).legacyNumber;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Derived-view ordering (SPEC-002 contract 8): ascending by date, tiebroken
|
|
1330
|
+
// by display number when present — numbered artifacts sort before unnumbered
|
|
1331
|
+
// ones inside a date group (the numbered era predates the slug era) — else
|
|
1332
|
+
// by slug. Callers map artifacts to { date, number, slug }.
|
|
1333
|
+
export function compareArtifactOrder(x, y) {
|
|
1334
|
+
const dx = String(x.date || "");
|
|
1335
|
+
const dy = String(y.date || "");
|
|
1336
|
+
if (dx !== dy) return dx < dy ? -1 : 1;
|
|
1337
|
+
const nx = x.number != null;
|
|
1338
|
+
const ny = y.number != null;
|
|
1339
|
+
if (nx !== ny) return nx ? -1 : 1;
|
|
1340
|
+
if (nx && ny) {
|
|
1341
|
+
const dn = parseInt(x.number, 10) - parseInt(y.number, 10);
|
|
1342
|
+
if (dn) return dn;
|
|
1343
|
+
}
|
|
1344
|
+
return String(x.slug || "").localeCompare(String(y.slug || ""));
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
// Pre-write uniqueness guard (SPEC-002 contract 4): does `target` collide
|
|
1348
|
+
// with any existing name once both are normalized? Candidate-set
|
|
1349
|
+
// intersection, so it sees cross-era collisions an exact `test -e` cannot.
|
|
1350
|
+
// Read-only — callers pass the directory listing; draft.mjs surfaces the
|
|
1351
|
+
// result as its `collision` output field and command prose only renders it.
|
|
1352
|
+
// Returns null or { with, identity, selfMatch, digitLeading }: selfMatch
|
|
1353
|
+
// means both as-written readings coincide (a plain duplicate); digitLeading
|
|
1354
|
+
// means a digit-leading reading is involved on either side (warn-class).
|
|
1355
|
+
export function findSlugCollision(target, existingNames, opts = {}) {
|
|
1356
|
+
const t = slugIdentity(target, opts);
|
|
1357
|
+
const tIds = new Set(t.candidates.map((c) => c.id));
|
|
1358
|
+
for (const name of existingNames) {
|
|
1359
|
+
const e = slugIdentity(name, opts);
|
|
1360
|
+
const shared = e.candidates.find((c) => tIds.has(c.id));
|
|
1361
|
+
if (!shared) continue;
|
|
1362
|
+
return {
|
|
1363
|
+
with: name,
|
|
1364
|
+
identity: shared.id,
|
|
1365
|
+
selfMatch: t.primary === e.primary,
|
|
1366
|
+
digitLeading: t.digitLeading || e.digitLeading,
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
return null;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// ─── Story discovery ──────────────────────────────────────────────────
|
|
1373
|
+
//
|
|
1374
|
+
// A story is written in one of two shapes, and both are load-bearing in real
|
|
1375
|
+
// vaults:
|
|
1376
|
+
//
|
|
1377
|
+
// stories/story-001-foo.md — flat file
|
|
1378
|
+
// stories/story-001-foo/README.md — folder, when the story owns artifacts
|
|
1379
|
+
//
|
|
1380
|
+
// The folder shape exists because a story that carries attachments (reviews,
|
|
1381
|
+
// diagrams, drafts) needs somewhere to put them; the README is then the story
|
|
1382
|
+
// itself. Scanners that only glob `stories/*.md` silently drop those stories —
|
|
1383
|
+
// on a board that means the card disappears, which reads as "no such work"
|
|
1384
|
+
// rather than "scanner is blind".
|
|
1385
|
+
//
|
|
1386
|
+
// Only `stories/<name>/README.md` counts, not deeper nesting: an
|
|
1387
|
+
// `artifacts/` subfolder under a story holds attachments, not more stories.
|
|
1388
|
+
|
|
1389
|
+
export function listStoryFiles(storiesDir) {
|
|
1390
|
+
if (!existsSync(storiesDir)) return [];
|
|
1391
|
+
const out = [];
|
|
1392
|
+
for (const entry of readdirSync(storiesDir).sort()) {
|
|
1393
|
+
const full = join(storiesDir, entry);
|
|
1394
|
+
let st;
|
|
1395
|
+
try { st = statSync(full); } catch { continue; }
|
|
1396
|
+
if (st.isFile() && entry.endsWith(".md")) {
|
|
1397
|
+
out.push({ abs: full, rel: entry, slug: entry.replace(/\.md$/, "") });
|
|
1398
|
+
continue;
|
|
1399
|
+
}
|
|
1400
|
+
if (st.isDirectory()) {
|
|
1401
|
+
const readme = join(full, "README.md");
|
|
1402
|
+
if (existsSync(readme)) {
|
|
1403
|
+
out.push({ abs: readme, rel: `${entry}/README.md`, slug: entry });
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
return out;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Every story belonging to one epic folder, `rel` given relative to that folder.
|
|
1411
|
+
//
|
|
1412
|
+
// Besides the two shapes above there is a third: a standalone story — one that
|
|
1413
|
+
// has its own tracker key and no epic around it, filed as
|
|
1414
|
+
// epics/<key>/story-<slug>.md with no stories/ subfolder. Callers address it by
|
|
1415
|
+
// that path (ADRs link straight to it), so it is a real location, not a mistake
|
|
1416
|
+
// to normalise away.
|
|
1417
|
+
|
|
1418
|
+
export function listEpicStories(epicDir) {
|
|
1419
|
+
const out = [];
|
|
1420
|
+
// epics/ holds loose files too (README, notes) — only folders are epics.
|
|
1421
|
+
try { if (!statSync(epicDir).isDirectory()) return out; } catch { return out; }
|
|
1422
|
+
for (const s of listStoryFiles(join(epicDir, "stories"))) {
|
|
1423
|
+
out.push({ abs: s.abs, rel: `stories/${s.rel}`, slug: s.slug });
|
|
1424
|
+
}
|
|
1425
|
+
for (const entry of readdirSync(epicDir).sort()) {
|
|
1426
|
+
if (!entry.startsWith("story-") || !entry.endsWith(".md")) continue;
|
|
1427
|
+
const full = join(epicDir, entry);
|
|
1428
|
+
try { if (!statSync(full).isFile()) continue; } catch { continue; }
|
|
1429
|
+
out.push({ abs: full, rel: entry, slug: entry.replace(/\.md$/, "") });
|
|
1430
|
+
}
|
|
1431
|
+
return out;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// ─── Link graph: extraction, node index, resolver ──────────────────────
|
|
1435
|
+
// (spec: vault-link-graph-derived-view-and-shared-link-resolver)
|
|
1436
|
+
|
|
1437
|
+
// Strip fenced blocks and inline code spans before matching links or
|
|
1438
|
+
// checkboxes — notation inside code is not a link. Lifted from doctor,
|
|
1439
|
+
// which carried two byte-identical copies (checkbox counting and
|
|
1440
|
+
// checkWikilinks); one definition, shared by doctor and the graph.
|
|
1441
|
+
export function stripCodeSpans(s) {
|
|
1442
|
+
return s.replace(/```[\s\S]*?```/g, "").replace(/`[^`\n]*`/g, "");
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Every link in one file's text: wikilinks and relative markdown links.
|
|
1446
|
+
// Full file text goes in, frontmatter included — parity with what doctor's
|
|
1447
|
+
// checkWikilinks always scanned. The alias split tolerates the escaped
|
|
1448
|
+
// `\|` form generated tables render, so a trailing backslash never leaks
|
|
1449
|
+
// into the target. Markdown links count only in their ./ and ../ forms —
|
|
1450
|
+
// URLs and absolute paths were never links doctor checked, and stay out.
|
|
1451
|
+
export function extractLinks(text) {
|
|
1452
|
+
const prose = stripCodeSpans(text);
|
|
1453
|
+
const out = [];
|
|
1454
|
+
for (const m of prose.matchAll(/\[\[([^\]]+)\]\]/g)) {
|
|
1455
|
+
const target = m[1].split(/\\?\|/)[0].split("#")[0].trim();
|
|
1456
|
+
if (target) out.push({ type: "wikilink", target });
|
|
1457
|
+
}
|
|
1458
|
+
for (const m of prose.matchAll(/\]\(([^)\s]+)\)/g)) {
|
|
1459
|
+
const t = m[1];
|
|
1460
|
+
if (!t.startsWith("./") && !t.startsWith("../")) continue;
|
|
1461
|
+
const target = t.split("#")[0];
|
|
1462
|
+
if (target) out.push({ type: "mdlink", target });
|
|
1463
|
+
}
|
|
1464
|
+
return out;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
// Pure /-joined path arithmetic for link resolution. node:path is
|
|
1468
|
+
// deliberately avoided: node keys are /-joined vault-relative strings on
|
|
1469
|
+
// every platform, and resolve()/join() would reintroduce win32 separators.
|
|
1470
|
+
// Returns the normalized relative path, or null when the target escapes
|
|
1471
|
+
// the base (a root-relative try that climbs out of the vault is rejected —
|
|
1472
|
+
// a stray file in the vault's parent folder must never shadow a hit).
|
|
1473
|
+
function joinRel(baseSegments, target) {
|
|
1474
|
+
const segs = [...baseSegments];
|
|
1475
|
+
for (const part of String(target).split("/")) {
|
|
1476
|
+
if (!part || part === ".") continue;
|
|
1477
|
+
if (part === "..") {
|
|
1478
|
+
if (!segs.length) return null;
|
|
1479
|
+
segs.pop();
|
|
1480
|
+
} else {
|
|
1481
|
+
segs.push(part);
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
return segs.join("/");
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
const relDirSegments = (rel) => {
|
|
1488
|
+
const segs = String(rel).split("/");
|
|
1489
|
+
segs.pop();
|
|
1490
|
+
return segs;
|
|
1491
|
+
};
|
|
1492
|
+
|
|
1493
|
+
// The graph's node universe (spec contract 2): layout artifact kinds,
|
|
1494
|
+
// walked the way each kind's real consumers walk them — flat kind folders
|
|
1495
|
+
// with README.md skipped (scanArtifacts parity), the epic folder via
|
|
1496
|
+
// listEpicStories so folder-shape AND standalone epics/<id>/story-*.md
|
|
1497
|
+
// stories are nodes (board parity: a card on the kanban must never
|
|
1498
|
+
// classify out-of-scope; `_`/`.`-prefixed epic dirs hold blanks, not
|
|
1499
|
+
// work, exactly as kanban skips them). Derived views and READMEs are
|
|
1500
|
+
// never nodes. Node keys are full vault-relative paths — never short
|
|
1501
|
+
// names: epic.md ×4 and README.md ×9 collide in the reference vault
|
|
1502
|
+
// today, and slug-first identity (ADR-010) makes bare numbers weaker
|
|
1503
|
+
// over time.
|
|
1504
|
+
export function buildNodeIndex(cfg, layout) {
|
|
1505
|
+
const vault = cfg.vault_path;
|
|
1506
|
+
const nodes = [];
|
|
1507
|
+
const push = (abs, rel, type, { prefix = null, story = false } = {}) => {
|
|
1508
|
+
let md;
|
|
1509
|
+
try { md = readFileSync(abs, "utf8"); } catch { return; }
|
|
1510
|
+
const fm = parseFrontmatter(md).data;
|
|
1511
|
+
const name = basename(rel);
|
|
1512
|
+
// A folder-shape story is identified by its folder name, like doctor's
|
|
1513
|
+
// storyStemOf; every other node by its filename stem.
|
|
1514
|
+
const stem = name === "README.md"
|
|
1515
|
+
? basename(dirname(rel))
|
|
1516
|
+
: name.replace(/\.md$/i, "");
|
|
1517
|
+
const idField = fm.id ?? fm.slug; // research/concept/runbook/meeting templates carry slug:, not id:
|
|
1518
|
+
nodes.push({
|
|
1519
|
+
path: rel,
|
|
1520
|
+
abs,
|
|
1521
|
+
type,
|
|
1522
|
+
title: String(fm.title || stem),
|
|
1523
|
+
status: fm.status == null ? null : String(fm.status),
|
|
1524
|
+
fm,
|
|
1525
|
+
body: md,
|
|
1526
|
+
stem,
|
|
1527
|
+
identity: idField == null ? null : String(idField),
|
|
1528
|
+
// Tier-2 accepts the as-written stem PLUS every slugIdentity reading.
|
|
1529
|
+
// The as-written entry is load-bearing for stories: slugIdentity
|
|
1530
|
+
// strips the story- marker from its candidates, and without it a
|
|
1531
|
+
// link to a legacy story's full stem ([[story-013-<slug>]] — the
|
|
1532
|
+
// form Obsidian autocompletes) would miss every tier and land
|
|
1533
|
+
// out-of-scope, on a node the kanban shows as a card.
|
|
1534
|
+
stemReadings: [stem.toLowerCase(), ...slugIdentity(stem, { prefix, story }).candidates.map((c) => c.id)],
|
|
1535
|
+
prefix,
|
|
1536
|
+
story,
|
|
1537
|
+
});
|
|
1538
|
+
};
|
|
1539
|
+
for (const folder of layout.folders) {
|
|
1540
|
+
const dir = join(vault, folder.path);
|
|
1541
|
+
if (!existsSync(dir)) continue;
|
|
1542
|
+
if (folder.kind === "epic") {
|
|
1543
|
+
for (const id of readdirSync(dir).sort()) {
|
|
1544
|
+
if (id.startsWith("_") || id.startsWith(".")) continue;
|
|
1545
|
+
const epicDir = join(dir, id);
|
|
1546
|
+
const epicMd = join(epicDir, "epic.md");
|
|
1547
|
+
if (existsSync(epicMd)) push(epicMd, `${folder.path}/${id}/epic.md`, "epic");
|
|
1548
|
+
for (const s of listEpicStories(epicDir)) {
|
|
1549
|
+
push(s.abs, `${folder.path}/${id}/${s.rel}`, "story", { story: true });
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
} else {
|
|
1553
|
+
for (const f of readdirSync(dir).sort()) {
|
|
1554
|
+
if (!f.endsWith(".md") || f === "README.md") continue;
|
|
1555
|
+
push(join(dir, f), `${folder.path}/${f}`, folder.kind, { prefix: folder.prefix || null });
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
const byPath = new Map(nodes.map((n) => [n.path, n]));
|
|
1560
|
+
const byIdentity = new Map();
|
|
1561
|
+
const byStem = new Map();
|
|
1562
|
+
const add = (map, key, node) => {
|
|
1563
|
+
const k = key.toLowerCase();
|
|
1564
|
+
if (!map.has(k)) map.set(k, []);
|
|
1565
|
+
if (!map.get(k).includes(node)) map.get(k).push(node);
|
|
1566
|
+
};
|
|
1567
|
+
for (const n of nodes) {
|
|
1568
|
+
if (n.identity) add(byIdentity, n.identity, n);
|
|
1569
|
+
for (const r of n.stemReadings) add(byStem, r, n);
|
|
1570
|
+
}
|
|
1571
|
+
return { nodes, byPath, byIdentity, byStem };
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// The ONE link resolver (spec contract 3), shared by the graph generator
|
|
1575
|
+
// and doctor's wikilink check so "dead" means the same thing in both.
|
|
1576
|
+
// Outcomes: {outcome: "node", node} | {outcome: "out-of-scope", path?} |
|
|
1577
|
+
// {outcome: "ambiguous", candidates} | {outcome: "dead"}.
|
|
1578
|
+
//
|
|
1579
|
+
// A target containing "/" resolves as a PATH — vault-root-relative first,
|
|
1580
|
+
// then source-file-relative, ".md" appended when missing — and never
|
|
1581
|
+
// enters the stem tiers. A path-qualified target that resolves in neither
|
|
1582
|
+
// try is dead: deliberately stricter than Obsidian, whose basename
|
|
1583
|
+
// fallback silently heals a wrong relative depth. Bare stems run the
|
|
1584
|
+
// SPEC-002 tiers over the NODE index (exact frontmatter identity, exact
|
|
1585
|
+
// filename-stem readings, legacy numbered-prefix fallback gated by
|
|
1586
|
+
// isLegacyNumberedId — slug-form targets match exactly, never generic
|
|
1587
|
+
// startsWith); the strongest tier wins and a tie within it is ambiguity,
|
|
1588
|
+
// never a silent first match (resolveSpecStory's rule). On zero node
|
|
1589
|
+
// candidates, ring 2 — an exact-stem match against the full vault file
|
|
1590
|
+
// walk — classifies a hit out-of-scope: the target exists and is not a
|
|
1591
|
+
// node; which non-node file a stem like README means is not the graph's
|
|
1592
|
+
// business (path reported only when the hit is unique). Zero hits in
|
|
1593
|
+
// either ring is dead. All stem comparisons are case-insensitive,
|
|
1594
|
+
// preserving today's checkWikilinks semantics.
|
|
1595
|
+
//
|
|
1596
|
+
// ctx: { sourceRel, index, files, exists?, kinds? }
|
|
1597
|
+
// files — [{rel, name}] full .md walk (doctor's walkVaultFiles shape),
|
|
1598
|
+
// INJECTED so lib never imports doctor (no import cycle).
|
|
1599
|
+
// exists — (vaultRel) => bool for non-.md targets (attachments);
|
|
1600
|
+
// defaults to "no" — fs-backed callers supply the real one.
|
|
1601
|
+
// kinds — restrict node tiers to these node types (frontmatter refs
|
|
1602
|
+
// are kind-scoped by their field; body links pass no filter,
|
|
1603
|
+
// so a cross-kind multi-hit is ambiguous).
|
|
1604
|
+
export function resolveLinkTarget(rawTarget, linkType, ctx) {
|
|
1605
|
+
const { sourceRel, index, files, exists = () => false, kinds = null } = ctx;
|
|
1606
|
+
const target = String(rawTarget).trim();
|
|
1607
|
+
const eligible = (n) => !kinds || kinds.includes(n.type);
|
|
1608
|
+
const finish = (rel) => {
|
|
1609
|
+
const node = index.byPath.get(rel);
|
|
1610
|
+
if (node && eligible(node)) return { outcome: "node", node };
|
|
1611
|
+
return { outcome: "out-of-scope", path: rel };
|
|
1612
|
+
};
|
|
1613
|
+
const fileSet = ctx._fileSet ?? (ctx._fileSet = new Set(files.map((f) => f.rel)));
|
|
1614
|
+
|
|
1615
|
+
if (linkType === "mdlink") {
|
|
1616
|
+
// Relative markdown links resolve against the source file's directory
|
|
1617
|
+
// only — today's semantics. Landing outside the vault is out-of-scope
|
|
1618
|
+
// by definition (contract 3): no probing beyond the vault root.
|
|
1619
|
+
const rel = joinRel(relDirSegments(sourceRel), target);
|
|
1620
|
+
if (rel === null) return { outcome: "out-of-scope" };
|
|
1621
|
+
if (fileSet.has(rel)) return finish(rel);
|
|
1622
|
+
if (exists(rel)) return { outcome: "out-of-scope", path: rel };
|
|
1623
|
+
return { outcome: "dead" };
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
if (target.includes("/")) {
|
|
1627
|
+
const t = /\.md$/i.test(target) ? target : `${target}.md`;
|
|
1628
|
+
for (const base of [[], relDirSegments(sourceRel)]) {
|
|
1629
|
+
const rel = joinRel(base, t);
|
|
1630
|
+
if (rel !== null && fileSet.has(rel)) return finish(rel);
|
|
1631
|
+
}
|
|
1632
|
+
return { outcome: "dead" };
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
const key = target.toLowerCase();
|
|
1636
|
+
// Tier 1: exact frontmatter identity. Tier 2: filename-stem readings.
|
|
1637
|
+
// Tier 3: legacy numbered-prefix fallback, per node anchor.
|
|
1638
|
+
const tiers = [
|
|
1639
|
+
() => (index.byIdentity.get(key) || []).filter(eligible),
|
|
1640
|
+
() => (index.byStem.get(key) || []).filter(eligible),
|
|
1641
|
+
() => index.nodes.filter((n) =>
|
|
1642
|
+
eligible(n)
|
|
1643
|
+
&& isLegacyNumberedId(target, n.story ? { story: true } : { prefix: n.prefix })
|
|
1644
|
+
&& n.stem.toLowerCase().startsWith(`${key}-`)),
|
|
1645
|
+
];
|
|
1646
|
+
for (const tier of tiers) {
|
|
1647
|
+
const hits = [...new Set(tier())];
|
|
1648
|
+
if (hits.length === 1) return { outcome: "node", node: hits[0] };
|
|
1649
|
+
if (hits.length > 1) return { outcome: "ambiguous", candidates: hits.map((n) => n.path).sort() };
|
|
1650
|
+
}
|
|
1651
|
+
if (!kinds) {
|
|
1652
|
+
// Ring 2 — only for body links: frontmatter refs point at artifacts
|
|
1653
|
+
// by contract, so a non-node reference is simply dead.
|
|
1654
|
+
const ring2 = files.filter((f) => f.name.replace(/\.md$/i, "").toLowerCase() === key);
|
|
1655
|
+
if (ring2.length === 1) return { outcome: "out-of-scope", path: ring2[0].rel };
|
|
1656
|
+
if (ring2.length > 1) return { outcome: "out-of-scope" };
|
|
1657
|
+
}
|
|
1658
|
+
return { outcome: "dead" };
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
// ─── Vault navigation skeleton ────────────────────────────────────────
|
|
1662
|
+
// (spec: the-sessionstart-navigation-skeleton-bounded-layout-derived-vault-localized)
|
|
1663
|
+
//
|
|
1664
|
+
// renderVaultSkeleton(facts) is pure — no filesystem, no clock. Every read it
|
|
1665
|
+
// depends on was already made, under one deadline, by gatherVaultFacts. The
|
|
1666
|
+
// split is what makes the bounds unit-testable without a slow filesystem, and
|
|
1667
|
+
// what stops a second convenience being paid for out of the same budget later.
|
|
1668
|
+
|
|
1669
|
+
export const PURPOSE_CELL = 160; // contract 1
|
|
1670
|
+
export const TITLE_CELL = 80; // contract 1
|
|
1671
|
+
export const PATH_CELL = 200; // contracts 1, 19 — measured: longest path here is 123
|
|
1672
|
+
export const INFLIGHT_CAP = 5; // contracts 1, 19
|
|
1673
|
+
export const ERROR_CELL = 500; // contract 3
|
|
1674
|
+
|
|
1675
|
+
// A truncation marks itself, and the mark counts toward the budget (contract 1).
|
|
1676
|
+
export function truncEnd(s, max) {
|
|
1677
|
+
const t = String(s ?? "");
|
|
1678
|
+
if (t.length <= max) return t;
|
|
1679
|
+
let cut = t.slice(0, max - 1);
|
|
1680
|
+
// Never orphan a table-cell escape: a `\|` sliced in half leaves a stray
|
|
1681
|
+
// backslash AND un-escapes the pipe that follows it, breaking the row.
|
|
1682
|
+
const tail = cut.match(/\\+$/);
|
|
1683
|
+
if (tail && tail[0].length % 2 === 1) cut = cut.slice(0, -1);
|
|
1684
|
+
return cut + "…";
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
// Front-truncation keeps the tail. For a path that is the discriminating half —
|
|
1688
|
+
// siblings share their folder prefix and differ only in slug — and it keeps the
|
|
1689
|
+
// filename, so the line still names the artifact to a reader (contract 19).
|
|
1690
|
+
export function truncFront(s, max) {
|
|
1691
|
+
const t = String(s ?? "");
|
|
1692
|
+
if (t.length <= max) return t;
|
|
1693
|
+
return "…" + t.slice(t.length - (max - 1));
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// Contract 6: a folder's purpose is its README's own prose — the slice above the
|
|
1697
|
+
// first `## ` heading. A README that opens with `## ` at byte 0 has no preamble.
|
|
1698
|
+
// Missing, empty or unreadable yields the folder's KIND, never an empty cell.
|
|
1699
|
+
export function folderPurpose(readmeText, kind) {
|
|
1700
|
+
const text = readmeText == null ? "" : String(readmeText);
|
|
1701
|
+
const m = text.match(/(^|\n)## /);
|
|
1702
|
+
const head = m ? text.slice(0, m.index) : text;
|
|
1703
|
+
const prose = head
|
|
1704
|
+
.split("\n")
|
|
1705
|
+
.filter((l) => !l.startsWith("#"))
|
|
1706
|
+
.join(" ")
|
|
1707
|
+
.replace(/\s+/g, " ")
|
|
1708
|
+
.trim()
|
|
1709
|
+
.replace(/\|/g, "\\|");
|
|
1710
|
+
return prose ? truncEnd(prose, PURPOSE_CELL) : String(kind);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// Contract 19 — a path cell, with the truncation mark OUTSIDE the copyable
|
|
1714
|
+
// token, so what a reader copies out of the backticks is a clean substring of
|
|
1715
|
+
// the real path. `graph.md` contains zero `…` characters, so a pasted cell
|
|
1716
|
+
// carrying one matches nothing and grep exits 1 — which reads as "this artifact
|
|
1717
|
+
// has no graph entries", a silently false answer. Decided by equality against
|
|
1718
|
+
// the untruncated string rather than by a leading "…", which is a character a
|
|
1719
|
+
// path is entitled to contain.
|
|
1720
|
+
export function pathCell(p) {
|
|
1721
|
+
const s = String(p ?? "");
|
|
1722
|
+
const t = truncFront(s, PATH_CELL);
|
|
1723
|
+
return t === s ? `\`${s}\`` : `…\`${t.slice(1)}\``;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function renderCount(counts) {
|
|
1727
|
+
if (!counts) return "0";
|
|
1728
|
+
if (counts.epics != null) {
|
|
1729
|
+
return `${counts.epics} epics · ${counts.stories ?? 0} stories`;
|
|
1730
|
+
}
|
|
1731
|
+
return String(counts.artifacts ?? 0);
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
function descentOrder({ kanbanFile, adrIndex, epicFile }) {
|
|
1735
|
+
return [
|
|
1736
|
+
`1. **What is in flight** — the list below, or \`${kanbanFile}\` for the whole board.`,
|
|
1737
|
+
`2. **The epic** — \`${epicFile}\` names its stories and how they map to code.`,
|
|
1738
|
+
"3. **A folder's index** — its `README.md` lists every artifact with title, status and date. One read, complete.",
|
|
1739
|
+
`4. **Before authoring an ADR or spec, or making an architectural choice — read \`${adrIndex}\`.** This step fires on *deciding*, not on searching: it is the one moment the decision index is load-bearing, and skipping it is how a settled question gets re-decided.`,
|
|
1740
|
+
"5. **An artifact's neighbourhood** — `grep '<vault-relative-path>' graph.md` returns its typed links in both directions, in one call.",
|
|
1741
|
+
];
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
// Contract 5 — per folder, non-recursive, `README.md` excluded. The epic folder
|
|
1745
|
+
// counts epics and stories separately, through the shared story lister so that
|
|
1746
|
+
// folder-shaped and standalone stories are counted the same way the rest of the
|
|
1747
|
+
// plugin sees them. readdir/stat only: contract 14 forbids anything here that
|
|
1748
|
+
// could materialize an evicted file, because this path has no budget at all.
|
|
1749
|
+
function countFolder(vault, folder) {
|
|
1750
|
+
const dir = join(vault, folder.path);
|
|
1751
|
+
let names;
|
|
1752
|
+
try {
|
|
1753
|
+
names = readdirSync(dir);
|
|
1754
|
+
} catch {
|
|
1755
|
+
return folder.subfolder_per_id ? { epics: 0, stories: 0 } : { artifacts: 0 };
|
|
1756
|
+
}
|
|
1757
|
+
if (folder.subfolder_per_id) {
|
|
1758
|
+
let epics = 0;
|
|
1759
|
+
let stories = 0;
|
|
1760
|
+
for (const n of names) {
|
|
1761
|
+
if (n.startsWith(".")) continue;
|
|
1762
|
+
const full = join(dir, n);
|
|
1763
|
+
try {
|
|
1764
|
+
if (!statSync(full).isDirectory()) continue;
|
|
1765
|
+
} catch {
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1768
|
+
// Contract 5 — an epic is a subdirectory containing `epic.md`, AND stories
|
|
1769
|
+
// come from the shared walker. Two clauses, and the gate belongs in front
|
|
1770
|
+
// of only the first: the walker does not require `epic.md`, so gating both
|
|
1771
|
+
// makes the count and the in-flight list disagree about the same vault
|
|
1772
|
+
// three lines apart — contract 12's argument at a smaller scale.
|
|
1773
|
+
stories += listEpicStories(full).length;
|
|
1774
|
+
if (!existsSync(join(full, "epic.md"))) continue;
|
|
1775
|
+
epics += 1;
|
|
1776
|
+
}
|
|
1777
|
+
return { epics, stories };
|
|
1778
|
+
}
|
|
1779
|
+
return { artifacts: names.filter((n) => n.endsWith(".md") && n !== "README.md").length };
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// Contracts 12–15, 19–21 — every read this payload needs, under ONE deadline.
|
|
1783
|
+
//
|
|
1784
|
+
// Three families race a single timer: the folder READMEs, the in-flight story
|
|
1785
|
+
// scan, and (on `compact` only) the activity log. One timer rather than three
|
|
1786
|
+
// because the budget is the user's startup latency, which does not divide.
|
|
1787
|
+
// Each family degrades on its own terms, and a family that finished before
|
|
1788
|
+
// expiry keeps its result — partial is the normal outcome, not a failure.
|
|
1789
|
+
//
|
|
1790
|
+
// Nothing here is synchronous except enumeration. The synchronous reader this
|
|
1791
|
+
// module used to carry was deleted with this change rather than left as an
|
|
1792
|
+
// invitation: a synchronous read of an evicted file blocks uninterruptibly
|
|
1793
|
+
// inside one call and the timer never gets a turn, so the budget is only real
|
|
1794
|
+
// if every content read goes through `readActivityAsync`.
|
|
1795
|
+
export async function gatherVaultFacts(cfg, opts = {}) {
|
|
1796
|
+
// Trailing slashes: `bind` normalizes, a hand-edited config may not, and the
|
|
1797
|
+
// relativization below is raw arithmetic. Normalizing once here keeps the
|
|
1798
|
+
// gather agreeing with resolveInFlightArtifact, which already normalizes.
|
|
1799
|
+
const vault = String(cfg.vault_path || "").replace(/\/+$/, "");
|
|
1800
|
+
const budgetMs = opts.budgetMs ?? 200;
|
|
1801
|
+
const readFile = opts.readFile || ((p) => readFileAsync(p, "utf8"));
|
|
1802
|
+
const sessionId = opts.sessionId ?? null;
|
|
1803
|
+
const source = opts.source ?? null;
|
|
1804
|
+
|
|
1805
|
+
// Contract 17 — the vault-not-found shape keeps working. Without this the
|
|
1806
|
+
// renderer answers with eight rows of authoritative zeros and the line
|
|
1807
|
+
// "nothing in progress" about a vault that does not exist: exactly the
|
|
1808
|
+
// silently-false claim contracts 13 and 21 spend paragraphs refusing.
|
|
1809
|
+
if (!existsSync(vault)) return { vaultMissing: true, vaultPath: vault };
|
|
1810
|
+
|
|
1811
|
+
const layout = loadLayout(cfg.layout);
|
|
1812
|
+
const vcfg = readVaultConfig(vault);
|
|
1813
|
+
const adrFolder = folderByKind(layout, "adr") || folderByKind(layout, "spec");
|
|
1814
|
+
const epicFolder = folderByKind(layout, "epic");
|
|
1815
|
+
|
|
1816
|
+
const folders = layout.folders.map((f) => ({
|
|
1817
|
+
path: f.path,
|
|
1818
|
+
kind: f.kind,
|
|
1819
|
+
counts: countFolder(vault, f),
|
|
1820
|
+
readme: null, // a read that lands fills this; contract 6 covers the rest
|
|
1821
|
+
}));
|
|
1822
|
+
|
|
1823
|
+
const storyFiles = listVaultStoryFiles(vault);
|
|
1824
|
+
const inFlight = { status: "ok", entries: [], total: 0 };
|
|
1825
|
+
// Present only on `compact` — contract 19's positive test, applied where the
|
|
1826
|
+
// cost is: on every other source the log is not read at all.
|
|
1827
|
+
const wantContinuity = source === "compact" && Boolean(sessionId);
|
|
1828
|
+
const continuity = wantContinuity ? { status: "ok", paths: [], total: 0, artifact: null } : null;
|
|
1829
|
+
|
|
1830
|
+
const done = { readmes: false, inFlight: false, activity: false };
|
|
1831
|
+
|
|
1832
|
+
const readmes = (async () => {
|
|
1833
|
+
for (const f of folders) {
|
|
1834
|
+
try {
|
|
1835
|
+
f.readme = String(await readFile(join(vault, f.path, "README.md")));
|
|
1836
|
+
} catch {
|
|
1837
|
+
/* contract 6: missing, empty and unreadable all fall back to the kind */
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
done.readmes = true;
|
|
1841
|
+
})();
|
|
1842
|
+
|
|
1843
|
+
const stories = (async () => {
|
|
1844
|
+
const found = [];
|
|
1845
|
+
for (const abs of storyFiles) {
|
|
1846
|
+
let text;
|
|
1847
|
+
try {
|
|
1848
|
+
text = await readFile(abs);
|
|
1849
|
+
} catch {
|
|
1850
|
+
continue;
|
|
1851
|
+
}
|
|
1852
|
+
const fm = parseFrontmatter(String(text)).data;
|
|
1853
|
+
if (!fm || !isInProgress(fm.status)) continue;
|
|
1854
|
+
const rel = abs.startsWith(vault + "/") ? abs.slice(vault.length + 1) : abs;
|
|
1855
|
+
const seg = rel.split("/");
|
|
1856
|
+
const epic = epicFolder && seg[0] === epicFolder.path && seg[1] ? seg[1] : seg[0];
|
|
1857
|
+
found.push({ epic, title: String(fm.title || seg[seg.length - 1]), startedAt: fm.started_at || "" });
|
|
1858
|
+
}
|
|
1859
|
+
// Contract 15 — most recently started first, walker order as the tie-break,
|
|
1860
|
+
// which sort() preserves. Unstated, the cap would silently favour whichever
|
|
1861
|
+
// epic sorts first alphabetically, forever.
|
|
1862
|
+
found.sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
|
|
1863
|
+
inFlight.entries = found;
|
|
1864
|
+
inFlight.total = found.length;
|
|
1865
|
+
done.inFlight = true;
|
|
1866
|
+
})();
|
|
1867
|
+
|
|
1868
|
+
const activity = (async () => {
|
|
1869
|
+
if (!wantContinuity) {
|
|
1870
|
+
done.activity = true;
|
|
1871
|
+
return;
|
|
1872
|
+
}
|
|
1873
|
+
const entries = await readActivityAsync(vault, sessionId, readFile);
|
|
1874
|
+
const rel = entries
|
|
1875
|
+
.filter((e) => e && typeof e.path === "string" && isInsideVault(e.path, vault))
|
|
1876
|
+
.map((e) => e.path.slice(vault.length + 1))
|
|
1877
|
+
.filter(Boolean);
|
|
1878
|
+
continuity.paths = rel;
|
|
1879
|
+
continuity.total = rel.length;
|
|
1880
|
+
continuity.artifact = resolveInFlightArtifact(entries, layout, vault);
|
|
1881
|
+
done.activity = true;
|
|
1882
|
+
})();
|
|
1883
|
+
|
|
1884
|
+
let timer = null;
|
|
1885
|
+
const deadline = new Promise((resolve) => {
|
|
1886
|
+
timer = setTimeout(() => resolve("timeout"), budgetMs);
|
|
1887
|
+
});
|
|
1888
|
+
// Each family swallows its own rejection. Without this, a family that
|
|
1889
|
+
// rejects AFTER the deadline won leaves a derived promise with no handler:
|
|
1890
|
+
// node's default `--unhandled-rejections=throw` then exits the hook non-zero,
|
|
1891
|
+
// which is the "a hook never breaks session startup" contract 17 forbids. The
|
|
1892
|
+
// reachable trigger is a corrupt activity entry whose `path` is not a string.
|
|
1893
|
+
await Promise.race([
|
|
1894
|
+
Promise.all([readmes, stories, activity].map((p) => p.catch(() => {}))).then(() => "ok"),
|
|
1895
|
+
deadline,
|
|
1896
|
+
]);
|
|
1897
|
+
clearTimeout(timer);
|
|
1898
|
+
|
|
1899
|
+
// Contract 13 — a family still outstanding degrades to its NAMED line. An
|
|
1900
|
+
// unfinished in-flight scan must not render as an empty list: that asserts
|
|
1901
|
+
// the vault is idle, which is a different and possibly false claim.
|
|
1902
|
+
if (!done.inFlight) inFlight.status = "timeout";
|
|
1903
|
+
if (continuity && !done.activity) continuity.status = "timeout";
|
|
1904
|
+
|
|
1905
|
+
return {
|
|
1906
|
+
vaultPath: vault,
|
|
1907
|
+
layoutName: layout.name || cfg.layout,
|
|
1908
|
+
language: cfg.language || "en",
|
|
1909
|
+
specPolicy: vcfg.spec_policy || "optional",
|
|
1910
|
+
lifecycleGates: vcfg.lifecycle_gates || "on",
|
|
1911
|
+
kanbanFile: (layout.kanban && layout.kanban.file) || "kanban.md",
|
|
1912
|
+
adrIndex: adrFolder ? `${adrFolder.path}/README.md` : "README.md",
|
|
1913
|
+
epicFile: epicFolder ? `${epicFolder.path}/<EPIC>/epic.md` : "epic.md",
|
|
1914
|
+
folders,
|
|
1915
|
+
inFlight,
|
|
1916
|
+
continuity,
|
|
1917
|
+
};
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
export function renderVaultSkeleton(facts) {
|
|
1921
|
+
const f = facts || {};
|
|
1922
|
+
const L = [];
|
|
1923
|
+
|
|
1924
|
+
if (f.vaultMissing) {
|
|
1925
|
+
return `# projectstore: vault not found at ${truncFront(String(f.vaultPath ?? ""), PATH_CELL)}\n`;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// Contract 10 — the header carries the session-relevant policy. Absent vault
|
|
1929
|
+
// config renders the documented defaults rather than blanks.
|
|
1930
|
+
// Contract 3 — the vault path is bounded only by PATH_MAX, so it is a term of
|
|
1931
|
+
// the composed cap like any other. Front-truncated for contract 19's second
|
|
1932
|
+
// reason: the tail is the discriminating half of a path.
|
|
1933
|
+
L.push(`# Projectstore vault: ${truncFront(String(f.vaultPath ?? ""), PATH_CELL)}`);
|
|
1934
|
+
// Contract 3 — these four are user-supplied config, and config is free text.
|
|
1935
|
+
// The previous revision interpolated them raw, which put the 10,000-character
|
|
1936
|
+
// breach back into the very line this change added: a 3,000-character
|
|
1937
|
+
// `language` composed a 12,049-character payload. A structural bound is
|
|
1938
|
+
// exactly as good as its enumeration, and this was the third miss.
|
|
1939
|
+
const cell = (v, dflt) => truncEnd(String(v || dflt), TITLE_CELL);
|
|
1940
|
+
L.push(
|
|
1941
|
+
`# Layout: ${cell(f.layoutName, "unknown")} · language: ${cell(f.language, "en")}` +
|
|
1942
|
+
` · spec_policy: ${cell(f.specPolicy, "optional")}` +
|
|
1943
|
+
` · lifecycle_gates: ${cell(f.lifecycleGates, "on")}`,
|
|
1944
|
+
);
|
|
1945
|
+
L.push("");
|
|
1946
|
+
|
|
1947
|
+
// Contract 9 — all five steps, resolved through the layout, never typed.
|
|
1948
|
+
L.push("## How to work with this vault");
|
|
1949
|
+
L.push("");
|
|
1950
|
+
L.push("Descend on demand. Nothing below is a copy of the vault; it is the order to read it in.");
|
|
1951
|
+
L.push("");
|
|
1952
|
+
for (const step of descentOrder(f)) L.push(step);
|
|
1953
|
+
L.push("");
|
|
1954
|
+
|
|
1955
|
+
// Contract 4 — one row per layout folder, iterated, never listed literally.
|
|
1956
|
+
L.push("## Where things live");
|
|
1957
|
+
L.push("");
|
|
1958
|
+
L.push("| Folder | Kind | Count | Purpose |");
|
|
1959
|
+
L.push("|---|---|---|---|");
|
|
1960
|
+
for (const folder of f.folders || []) {
|
|
1961
|
+
L.push(
|
|
1962
|
+
`| \`${folder.path}/\` | ${folder.kind} | ${renderCount(folder.counts)} | ` +
|
|
1963
|
+
`${folderPurpose(folder.readme, folder.kind)} |`,
|
|
1964
|
+
);
|
|
1965
|
+
}
|
|
1966
|
+
L.push("");
|
|
1967
|
+
|
|
1968
|
+
// Contracts 15, 21 — ordered most-recently-started first, capped, and the
|
|
1969
|
+
// expired case says so rather than rendering an empty list, which would be a
|
|
1970
|
+
// different and possibly false claim.
|
|
1971
|
+
L.push("## In flight now");
|
|
1972
|
+
L.push("");
|
|
1973
|
+
const inf = f.inFlight || {};
|
|
1974
|
+
if (inf.status === "timeout") {
|
|
1975
|
+
L.push(`- in-flight work not resolved within budget — see \`${f.kanbanFile}\` § In Progress`);
|
|
1976
|
+
} else {
|
|
1977
|
+
const entries = inf.entries || [];
|
|
1978
|
+
if (entries.length === 0) {
|
|
1979
|
+
L.push("- nothing in progress");
|
|
1980
|
+
} else {
|
|
1981
|
+
for (const e of entries.slice(0, INFLIGHT_CAP)) {
|
|
1982
|
+
// `epic` is a directory name — bounded only by NAME_MAX, five times over.
|
|
1983
|
+
L.push(`- ${truncEnd(e.epic, TITLE_CELL)} · ${truncEnd(e.title, TITLE_CELL)}`);
|
|
1984
|
+
}
|
|
1985
|
+
const more = (inf.total ?? entries.length) - Math.min(entries.length, INFLIGHT_CAP);
|
|
1986
|
+
if (more > 0) L.push(`- …and ${more} more; see \`${f.kanbanFile}\``);
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
L.push("");
|
|
1990
|
+
|
|
1991
|
+
// Contracts 19, 21 — the continuity section. Present only when the gather was
|
|
1992
|
+
// asked for it, which is only on `source === "compact"`: a positive test, in
|
|
1993
|
+
// one place. Absence here asserts nothing at all, which is why empty and
|
|
1994
|
+
// unreadable may share it while an empty in-flight list may not.
|
|
1995
|
+
const cont = f.continuity;
|
|
1996
|
+
if (cont) {
|
|
1997
|
+
if (cont.status === "timeout") {
|
|
1998
|
+
L.push("## Where this session left off");
|
|
1999
|
+
L.push("");
|
|
2000
|
+
L.push("- recent activity not resolved within budget — run `/projectstore:status`");
|
|
2001
|
+
L.push("");
|
|
2002
|
+
} else if (cont.paths && cont.paths.length > 0) {
|
|
2003
|
+
L.push("## Where this session left off");
|
|
2004
|
+
L.push("");
|
|
2005
|
+
L.push("Vault files this conversation touched before it was compacted, newest first.");
|
|
2006
|
+
L.push("");
|
|
2007
|
+
for (const p of cont.paths.slice(0, INFLIGHT_CAP)) L.push(`- ${pathCell(p)}`);
|
|
2008
|
+
const more = (cont.total ?? cont.paths.length) - Math.min(cont.paths.length, INFLIGHT_CAP);
|
|
2009
|
+
if (more > 0) L.push(`- …and ${more} more; see \`/projectstore:status\``);
|
|
2010
|
+
if (cont.artifact) {
|
|
2011
|
+
L.push("");
|
|
2012
|
+
L.push(`**In flight**: ${pathCell(cont.artifact)} was the newest structured write before` +
|
|
2013
|
+
" compaction. If we were drafting it, continue from there.");
|
|
2014
|
+
}
|
|
2015
|
+
L.push("");
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// Contracts 8, 11 — the recipe, the prohibition, and the staleness clause.
|
|
2020
|
+
L.push("## Derived views");
|
|
2021
|
+
L.push("");
|
|
2022
|
+
L.push(
|
|
2023
|
+
`\`${f.kanbanFile}\`, \`code-map.md\` and \`graph.md\` are **regenerated** from artifact` +
|
|
2024
|
+
" frontmatter. They can lag a very recent edit, they are never hand-edited, and the" +
|
|
2025
|
+
" artifact is the source of truth when they disagree.",
|
|
2026
|
+
);
|
|
2027
|
+
L.push("");
|
|
2028
|
+
L.push(
|
|
2029
|
+
"`graph.md` is queried, **never read whole** — it is far larger than this payload's" +
|
|
2030
|
+
" whole budget. Grep it by **vault-relative path**, which is its node key; a bare slug" +
|
|
2031
|
+
" is not a key and returns a flood (on one real vault, 22 lines by path against 101 by" +
|
|
2032
|
+
" slug). `code-map.md` answers where code for an epic already lives — read it before" +
|
|
2033
|
+
" deciding where new code goes.",
|
|
2034
|
+
);
|
|
2035
|
+
|
|
2036
|
+
return L.join("\n") + "\n";
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
// ─── Session awareness (layer 2 — multi-Claude coordination) ──────────
|
|
2040
|
+
//
|
|
2041
|
+
// Each Claude Code session registers itself in
|
|
2042
|
+
// <vault>/.projectstore/sessions/<id>.json, where <id> is Claude's own
|
|
2043
|
+
// session_id (from hook stdin input). Two Claude instances in the same
|
|
2044
|
+
// project therefore get distinct files. Other sessions reading the vault
|
|
2045
|
+
// can detect each other and warn the agent to avoid topic / numbering
|
|
2046
|
+
// collisions. mtime is used as a liveness proxy: a session whose file
|
|
2047
|
+
// has not been touched in 30 minutes is considered idle; >24h => stale,
|
|
2048
|
+
// removed on next SessionStart.
|
|
2049
|
+
|
|
2050
|
+
export function sessionsDir(vault) {
|
|
2051
|
+
return join(vault, LAYOUT.root, LAYOUT.vaultSessions);
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
export function sessionFilePath(vault, sessionId) {
|
|
2055
|
+
return join(sessionsDir(vault), `${sessionId}.json`);
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
// Read Claude's own session_id from hook stdin JSON. Returns null on any
|
|
2059
|
+
// parse error — callers must no-op silently in that case.
|
|
2060
|
+
export function readStdinJson() {
|
|
2061
|
+
try {
|
|
2062
|
+
const raw = readFileSync(0, "utf8").trim();
|
|
2063
|
+
if (!raw) return null;
|
|
2064
|
+
return JSON.parse(raw);
|
|
2065
|
+
} catch {
|
|
2066
|
+
return null;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
export function ensureSessionsDir(vault) {
|
|
2071
|
+
const dir = sessionsDir(vault);
|
|
2072
|
+
mkdirSync(dir, { recursive: true });
|
|
2073
|
+
// Make sure no session metadata leaks into git, regardless of where the
|
|
2074
|
+
// vault lives. The .gitignore inside <vault>/.projectstore/ is merged by
|
|
2075
|
+
// line (2026-09-06: a vault that is also a project shares the directory
|
|
2076
|
+
// with the project layout, whose committed harness/ a "*" would hide).
|
|
2077
|
+
ensureGitignoreLines(join(vault, LAYOUT.root, ".gitignore"), [`${LAYOUT.vaultSessions}/`], "projectstore — runtime data, do not commit");
|
|
2078
|
+
return dir;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
// Idempotent: preserves started_at and recent_activity if the session file
|
|
2082
|
+
// already exists (e.g. when SessionStart fires after touch-session has
|
|
2083
|
+
// already bootstrapped the record).
|
|
2084
|
+
export function writeSession(vault, sessionId, projectRoot) {
|
|
2085
|
+
ensureSessionsDir(vault);
|
|
2086
|
+
const path = sessionFilePath(vault, sessionId);
|
|
2087
|
+
let existing = null;
|
|
2088
|
+
if (existsSync(path)) {
|
|
2089
|
+
try { existing = JSON.parse(readFileSync(path, "utf8")); } catch {}
|
|
2090
|
+
}
|
|
2091
|
+
const data = {
|
|
2092
|
+
id: sessionId,
|
|
2093
|
+
started_at: existing?.started_at || new Date().toISOString(),
|
|
2094
|
+
project_root: projectRoot,
|
|
2095
|
+
host: hostname(),
|
|
2096
|
+
pid: process.pid,
|
|
2097
|
+
recent_activity: Array.isArray(existing?.recent_activity) ? existing.recent_activity : [],
|
|
2098
|
+
};
|
|
2099
|
+
writeFileSync(path, JSON.stringify(data, null, 2), "utf8");
|
|
2100
|
+
return path;
|
|
2101
|
+
}
|
|
2102
|
+
|
|
2103
|
+
export function touchSession(vault, sessionId) {
|
|
2104
|
+
const p = sessionFilePath(vault, sessionId);
|
|
2105
|
+
if (!existsSync(p)) return false;
|
|
2106
|
+
const now = new Date();
|
|
2107
|
+
try {
|
|
2108
|
+
utimesSync(p, now, now);
|
|
2109
|
+
return true;
|
|
2110
|
+
} catch {
|
|
2111
|
+
return false;
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
export function readActiveSessions(vault, currentSessionId, maxAgeMinutes = 30) {
|
|
2116
|
+
const dir = sessionsDir(vault);
|
|
2117
|
+
if (!existsSync(dir)) return [];
|
|
2118
|
+
const cutoff = Date.now() - maxAgeMinutes * 60 * 1000;
|
|
2119
|
+
const out = [];
|
|
2120
|
+
for (const name of readdirSync(dir)) {
|
|
2121
|
+
if (!name.endsWith(".json")) continue;
|
|
2122
|
+
const path = join(dir, name);
|
|
2123
|
+
let stat;
|
|
2124
|
+
try { stat = statSync(path); } catch { continue; }
|
|
2125
|
+
if (stat.mtimeMs < cutoff) continue;
|
|
2126
|
+
let data;
|
|
2127
|
+
try { data = JSON.parse(readFileSync(path, "utf8")); } catch { continue; }
|
|
2128
|
+
if (data.id === currentSessionId) continue;
|
|
2129
|
+
out.push({ ...data, last_active: stat.mtime });
|
|
2130
|
+
}
|
|
2131
|
+
return out;
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// Contract 23 — `currentSessionId` is exempt. A live session's file is not
|
|
2135
|
+
// stale, and reaping it is pure data destruction: `writeSession` recreates it
|
|
2136
|
+
// from nothing, so `recent_activity` and `started_at` are gone. A session left
|
|
2137
|
+
// open overnight and then compacted would have its own history deleted moments
|
|
2138
|
+
// before the continuity section asks for it.
|
|
2139
|
+
//
|
|
2140
|
+
// Named limitation: a SIBLING session idle beyond 24 hours is still reaped by
|
|
2141
|
+
// whichever session runs cleanup, and its next compaction renders absence for a
|
|
2142
|
+
// cause contract 21 does not name. Mtime cannot tell idle-alive from dead, so
|
|
2143
|
+
// the justification above applies to that session word for word and is not
|
|
2144
|
+
// cheaply actionable here.
|
|
2145
|
+
export function cleanupStaleSessions(vault, maxAgeHours = 24, currentSessionId = null) {
|
|
2146
|
+
const dir = sessionsDir(vault);
|
|
2147
|
+
if (!existsSync(dir)) return 0;
|
|
2148
|
+
const cutoff = Date.now() - maxAgeHours * 60 * 60 * 1000;
|
|
2149
|
+
const mine = currentSessionId ? `${currentSessionId}.json` : null;
|
|
2150
|
+
let removed = 0;
|
|
2151
|
+
for (const name of readdirSync(dir)) {
|
|
2152
|
+
if (!name.endsWith(".json")) continue;
|
|
2153
|
+
if (mine && name === mine) continue;
|
|
2154
|
+
const path = join(dir, name);
|
|
2155
|
+
try {
|
|
2156
|
+
if (statSync(path).mtimeMs < cutoff) {
|
|
2157
|
+
unlinkSync(path);
|
|
2158
|
+
removed++;
|
|
2159
|
+
}
|
|
2160
|
+
} catch {}
|
|
2161
|
+
}
|
|
2162
|
+
return removed;
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// (removeLegacySessionIdFile lived here until 2026-09-06; deleting the
|
|
2166
|
+
// 0.6-era .claude/.projectstore-session-id is a step of the layout migration.)
|
|
2167
|
+
// One-shot migration helper (retired): delete .claude/.projectstore-session-id left
|
|
2168
|
+
// behind by v0.3 – v0.5 (file-based per-project session id). Safe to call
|
|
2169
|
+
// on every session start; no-op if the file is absent. Kept until v0.7.
|
|
2170
|
+
|
|
2171
|
+
|
|
2172
|
+
// ─── Session activity log ──────────────────────────────────────────────
|
|
2173
|
+
//
|
|
2174
|
+
// Each session file may carry a `recent_activity` array, populated by
|
|
2175
|
+
// touch-session.mjs from PreToolUse events. Capped at 50 entries, deduped
|
|
2176
|
+
// by path (latest tool/timestamp wins). Read by hooks/pre-compact.mjs for its
|
|
2177
|
+
// compaction line and by hooks/session-start.mjs for the continuity section —
|
|
2178
|
+
// through the one resolver below, never by re-deriving the question.
|
|
2179
|
+
|
|
2180
|
+
const ACTIVITY_CAP = 50;
|
|
2181
|
+
|
|
2182
|
+
// The write family, defined once. touch-session.mjs writes the log with it and
|
|
2183
|
+
// resolveInFlightArtifact reads the log with it, so the reader cannot recognise
|
|
2184
|
+
// a narrower set than the writer recorded — which is exactly how `NotebookEdit`
|
|
2185
|
+
// came to be logged and then ignored. `hooks/hooks.json`'s PostToolUse matcher
|
|
2186
|
+
// is a third copy that cannot import; a test pins it against this list.
|
|
2187
|
+
// The list itself is the source manifest's (harnesses/claude-code.json); copied,
|
|
2188
|
+
// not aliased, so freezing it cannot freeze the cached manifest object.
|
|
2189
|
+
export const WRITE_TOOLS = Object.freeze([...sourceWriteTools()]);
|
|
2190
|
+
|
|
2191
|
+
export function isWriteTool(tool) {
|
|
2192
|
+
return harnessIsWriteTool(tool, process.env);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
export function toolPaths(input) {
|
|
2196
|
+
return harnessToolPaths(input, process.env);
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
export function appendActivity(vault, sessionId, filePath, toolName) {
|
|
2200
|
+
const sp = sessionFilePath(vault, sessionId);
|
|
2201
|
+
if (!existsSync(sp)) return false;
|
|
2202
|
+
let data;
|
|
2203
|
+
try {
|
|
2204
|
+
data = JSON.parse(readFileSync(sp, "utf8"));
|
|
2205
|
+
} catch {
|
|
2206
|
+
return false;
|
|
2207
|
+
}
|
|
2208
|
+
const recent = Array.isArray(data.recent_activity) ? data.recent_activity : [];
|
|
2209
|
+
const filtered = recent.filter((e) => e && e.path !== filePath);
|
|
2210
|
+
filtered.unshift({ path: filePath, tool: toolName, at: new Date().toISOString() });
|
|
2211
|
+
data.recent_activity = filtered.slice(0, ACTIVITY_CAP);
|
|
2212
|
+
try {
|
|
2213
|
+
writeFileSync(sp, JSON.stringify(data, null, 2));
|
|
2214
|
+
return true;
|
|
2215
|
+
} catch {
|
|
2216
|
+
return false;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2220
|
+
// The one reader of `recent_activity`, and async because a budget can only
|
|
2221
|
+
// interrupt an async read: a synchronous read of an iCloud-evicted file blocks
|
|
2222
|
+
// inside one call and the timer never gets a turn. Both consumers — the gather and
|
|
2223
|
+
// pre-compact — read this one file, so they read it through one function.
|
|
2224
|
+
// Returns [] for missing, unparseable and unreadable alike (contract 21).
|
|
2225
|
+
export async function readActivityAsync(vault, sessionId, readFile) {
|
|
2226
|
+
if (!sessionId) return [];
|
|
2227
|
+
const read = readFile || ((p) => readFileAsync(p, "utf8"));
|
|
2228
|
+
try {
|
|
2229
|
+
const data = JSON.parse(String(await read(sessionFilePath(vault, sessionId))));
|
|
2230
|
+
return Array.isArray(data.recent_activity) ? data.recent_activity : [];
|
|
2231
|
+
} catch {
|
|
2232
|
+
return [];
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
|
|
2236
|
+
// Contracts 20, 24 — the in-flight artifact: the newest write-family entry
|
|
2237
|
+
// whose path lands in a folder of the ACTIVE layout, returned vault-relative.
|
|
2238
|
+
//
|
|
2239
|
+
// Shared on purpose. The compaction line and the continuity section answer the
|
|
2240
|
+
// same question seconds apart on the same screen, so two implementations of it
|
|
2241
|
+
// drift in public. Folders come from the layout and are never spelled out here:
|
|
2242
|
+
// a layout that gains a kind must not go blind, which is the defect the row
|
|
2243
|
+
// renderer is already forbidden to have.
|
|
2244
|
+
//
|
|
2245
|
+
// `vaultPath` is a parameter rather than a convenience because the anchor
|
|
2246
|
+
// cannot be reconstructed from either side alone — the log stores absolute tool
|
|
2247
|
+
// paths, `layout.folders[].path` are vault-relative. Without it the only
|
|
2248
|
+
// available match is a substring, which fires on a folder name occurring at
|
|
2249
|
+
// depth: `notes/adr/x.md` is not an ADR.
|
|
2250
|
+
export function resolveInFlightArtifact(activity, layout, vaultPath) {
|
|
2251
|
+
if (!Array.isArray(activity) || !vaultPath) return null;
|
|
2252
|
+
const folders = (layout && Array.isArray(layout.folders) ? layout.folders : [])
|
|
2253
|
+
.map((f) => f && f.path)
|
|
2254
|
+
.filter(Boolean);
|
|
2255
|
+
if (folders.length === 0) return null;
|
|
2256
|
+
const root = vaultPath.endsWith("/") ? vaultPath.slice(0, -1) : vaultPath;
|
|
2257
|
+
// appendActivity unshifts, so the log is newest-first and the first match is
|
|
2258
|
+
// the newest one — no sort, and no second definition of "newest".
|
|
2259
|
+
for (const e of activity) {
|
|
2260
|
+
if (!e || !e.path || !isWriteTool(e.tool)) continue;
|
|
2261
|
+
if (!isInsideVault(e.path, root)) continue;
|
|
2262
|
+
const rel = e.path.slice(root.length + 1);
|
|
2263
|
+
if (folders.some((p) => rel === p || rel.startsWith(p + "/"))) return rel;
|
|
2264
|
+
}
|
|
2265
|
+
return null;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
export function isInsideVault(filePath, vaultPath) {
|
|
2269
|
+
if (!filePath || !vaultPath) return false;
|
|
2270
|
+
const norm = filePath.endsWith("/") ? filePath.slice(0, -1) : filePath;
|
|
2271
|
+
return norm === vaultPath || norm.startsWith(vaultPath + "/");
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
// ─── Per-session project-side state (statusline pointer — ADR-006) ─────
|
|
2275
|
+
//
|
|
2276
|
+
// <project>/.claude/.projectstore/state/<session_id>.json holds this
|
|
2277
|
+
// session's active epic/story with denormalized titles, so the statusline
|
|
2278
|
+
// renders with zero vault reads and zero cross-session reads. A nested
|
|
2279
|
+
// .gitignore with "*" is ensured unconditionally (mirrors ensureSessionsDir)
|
|
2280
|
+
// so per-session ids/titles never reach the user's git history.
|
|
2281
|
+
|
|
2282
|
+
// The WRITER's view: <project>/.projectstore/state/sessions. Readers fall back
|
|
2283
|
+
// to the legacy .claude/.projectstore/state through 0.29 (readSessionState).
|
|
2284
|
+
export function stateDir(projectDir) {
|
|
2285
|
+
return layoutPaths(projectDir).sessions;
|
|
2286
|
+
}
|
|
2287
|
+
export function legacyStateDir(projectDir) {
|
|
2288
|
+
return layoutPaths(projectDir).legacy.state;
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
export function sessionStatePath(projectDir, sessionId) {
|
|
2292
|
+
return join(stateDir(projectDir), `${sessionId}.json`);
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
// <project>/.projectstore — ours, harness-neutral. Its .gitignore is merged by
|
|
2296
|
+
// line (the binding and state/ are machine-specific; harness/ is committed).
|
|
2297
|
+
export function ensureRuntimeDir(projectDir) {
|
|
2298
|
+
const p = layoutPaths(projectDir);
|
|
2299
|
+
mkdirSync(p.root, { recursive: true });
|
|
2300
|
+
ensureGitignoreLines(p.gitignore, [...LAYOUT.gitignore], "projectstore — the binding and the state are machine-local; harness/ is committed");
|
|
2301
|
+
return p.root;
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
// <project>/.projectstore/state — every harness's runtime files, keyed by
|
|
2305
|
+
// harness inside; its own ignore file so nothing in here can reach git,
|
|
2306
|
+
// whichever writer gets there first.
|
|
2307
|
+
export function ensureStateDir(projectDir) {
|
|
2308
|
+
ensureRuntimeDir(projectDir);
|
|
2309
|
+
const p = layoutPaths(projectDir);
|
|
2310
|
+
mkdirSync(p.sessions, { recursive: true });
|
|
2311
|
+
if (!existsSync(p.stateGitignore)) writeFileSync(p.stateGitignore, `# ${RUNTIME_GITIGNORE_HEADER}, do not commit\n*\n`, "utf8");
|
|
2312
|
+
return p.sessions;
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
export function readSessionState(projectDir, sessionId) {
|
|
2316
|
+
try {
|
|
2317
|
+
const p = pickExisting(sessionStatePath(projectDir, sessionId), join(legacyStateDir(projectDir), `${sessionId}.json`));
|
|
2318
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
2319
|
+
} catch {
|
|
2320
|
+
return null;
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
export function writeSessionState(projectDir, sessionId, patch) {
|
|
2325
|
+
ensureStateDir(projectDir);
|
|
2326
|
+
const cur = readSessionState(projectDir, sessionId) || {};
|
|
2327
|
+
const next = { ...cur, ...patch, updated_at: new Date().toISOString() };
|
|
2328
|
+
// Atomic, because one session's hooks run concurrently: parallel tool calls
|
|
2329
|
+
// each fire PreToolUse. Two truncate-and-writes interleave into the shorter
|
|
2330
|
+
// JSON followed by the longer one's tail — CI run 37116511756, 2026-10-03:
|
|
2331
|
+
// "Unexpected non-whitespace character after JSON at position 139". While the
|
|
2332
|
+
// file is torn the status line reports the state unreadable; worse,
|
|
2333
|
+
// readSessionState answers null, so the next write keeps only its own patch
|
|
2334
|
+
// and the rest of the pointer is gone for good. The rename also hides the
|
|
2335
|
+
// empty instant after the truncate. The
|
|
2336
|
+
// default sweep stays on: this writer runs only on vault-file tool calls, so
|
|
2337
|
+
// it can afford the readdir that reaps orphan temps here (the status line's
|
|
2338
|
+
// breadcrumb, written beside it, opts out because it renders every refresh).
|
|
2339
|
+
writeFileAtomic(sessionStatePath(projectDir, sessionId), JSON.stringify(next, null, 2));
|
|
2340
|
+
return next;
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
export function cleanupStaleSessionState(projectDir, maxAgeHours = 24) {
|
|
2344
|
+
const dir = stateDir(projectDir);
|
|
2345
|
+
if (!existsSync(dir)) return 0;
|
|
2346
|
+
const cutoff = Date.now() - maxAgeHours * 60 * 60 * 1000;
|
|
2347
|
+
let removed = 0;
|
|
2348
|
+
for (const name of readdirSync(dir)) {
|
|
2349
|
+
const p = join(dir, name);
|
|
2350
|
+
try {
|
|
2351
|
+
const st = statSync(p);
|
|
2352
|
+
if (st.mtimeMs >= cutoff) continue;
|
|
2353
|
+
if (st.isDirectory()) {
|
|
2354
|
+
// Entry-rule score and marker directories. Reaped by the directory's
|
|
2355
|
+
// OWN mtime, which advances when an entry is created inside it — so an
|
|
2356
|
+
// actively-scoring session is never reaped mid-flight. The accepted
|
|
2357
|
+
// envelope: a session that registers no NEW distinct path for the whole
|
|
2358
|
+
// window loses its score and its markers together, permitting at most
|
|
2359
|
+
// one duplicate reminder. Before this branch existed they leaked
|
|
2360
|
+
// forever, because the old filter skipped everything but *.json.
|
|
2361
|
+
rmSync(p, { recursive: true, force: true });
|
|
2362
|
+
removed++;
|
|
2363
|
+
} else if (name.endsWith(".json")) {
|
|
2364
|
+
unlinkSync(p);
|
|
2365
|
+
removed++;
|
|
2366
|
+
}
|
|
2367
|
+
} catch {}
|
|
2368
|
+
}
|
|
2369
|
+
return removed;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
// ─── Entry-rule detection (artifact-first order) ───────────────────────
|
|
2373
|
+
//
|
|
2374
|
+
// Normative text: the spec "Entry-rule detection: the score, the open-story
|
|
2375
|
+
// predicate, and the delivery seams". The one rule that governs every helper
|
|
2376
|
+
// below and is invisible from any single call site: **nothing here may route
|
|
2377
|
+
// through writeSessionState**. That function is an unlocked read-modify-write:
|
|
2378
|
+
// it now publishes atomically, so a reader never sees a torn or empty pointer,
|
|
2379
|
+
// but two concurrent writers still race and the last rename wins — a patch
|
|
2380
|
+
// written in between is silently lost. Until 2026-10-03 it also truncated in
|
|
2381
|
+
// place (O_TRUNC), and a concurrent read then erased the ADR-006 statusline
|
|
2382
|
+
// pointer outright. Today that path only runs on vault-file tool calls; the
|
|
2383
|
+
// score is fed by every source write in every parallel subagent (all sharing
|
|
2384
|
+
// one session_id), where a lost increment is a wrong score.
|
|
2385
|
+
|
|
2386
|
+
// Generated, vendored or machine-local paths — never a source file for any
|
|
2387
|
+
// consumer. Lifted out of diff-refs.mjs so the hook, doctor and diff-refs
|
|
2388
|
+
// cannot drift apart (contract 1).
|
|
2389
|
+
export const SOURCE_IGNORE = [
|
|
2390
|
+
/(^|\/)package-lock\.json$/, /(^|\/)yarn\.lock$/, /(^|\/)pnpm-lock\.yaml$/,
|
|
2391
|
+
/(^|\/)Cargo\.lock$/, /(^|\/)node_modules\//, /(^|\/)dist\//, /(^|\/)build\//,
|
|
2392
|
+
/(^|\/)\.claude\//, /\.min\.(js|css)$/,
|
|
2393
|
+
];
|
|
2394
|
+
|
|
2395
|
+
// The entry-rule counter ignores strictly more than the base set, and the
|
|
2396
|
+
// difference is deliberate rather than an oversight of one or the other.
|
|
2397
|
+
// /projectstore:bind writes these three itself, in a session that by
|
|
2398
|
+
// construction has no story open — counting them makes the plugin nag about its
|
|
2399
|
+
// own setup. They must NOT join SOURCE_IGNORE: an edit to AGENTS.md is a real
|
|
2400
|
+
// code reference (the PS-AGENTS epic already lists it), and folding these into
|
|
2401
|
+
// the shared set would silently drop it from every proposed code_refs.
|
|
2402
|
+
// Root-anchored, unlike the patterns above: a monorepo's nested AGENTS.md is
|
|
2403
|
+
// ordinary source and must still count.
|
|
2404
|
+
export const ENTRY_IGNORE = [
|
|
2405
|
+
...SOURCE_IGNORE,
|
|
2406
|
+
/^AGENTS\.md$/, /^CLAUDE\.md$/, /^\.gitignore$/,
|
|
2407
|
+
];
|
|
2408
|
+
|
|
2409
|
+
export function isSourcePath(absPath, projectDir, vaultPath) {
|
|
2410
|
+
if (!absPath || !projectDir) return false;
|
|
2411
|
+
if (vaultPath && isInsideVault(absPath, vaultPath)) return false;
|
|
2412
|
+
const root = projectDir.endsWith("/") ? projectDir.slice(0, -1) : projectDir;
|
|
2413
|
+
if (!absPath.startsWith(root + "/")) return false;
|
|
2414
|
+
// Matched project-relative, never absolute: SOURCE_IGNORE is
|
|
2415
|
+
// repo-relative-anchored, so `/(^|\/)build\//` would swallow every path in a
|
|
2416
|
+
// project that merely lives under a directory called `build`.
|
|
2417
|
+
const rel = absPath.slice(root.length + 1);
|
|
2418
|
+
if (!rel) return false;
|
|
2419
|
+
return !ENTRY_IGNORE.some((re) => re.test(rel));
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
export function scoreDir(projectDir, sessionId) {
|
|
2423
|
+
return join(stateDir(projectDir), `${sessionId}.paths`);
|
|
2424
|
+
}
|
|
2425
|
+
|
|
2426
|
+
function pathKey(p) {
|
|
2427
|
+
return createHash("sha1").update(p).digest("hex").slice(0, 16);
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
// One empty file per distinct path. Registration is a bare create on a
|
|
2431
|
+
// content-derived name, so two subagents registering concurrently cannot lose
|
|
2432
|
+
// each other's increment and no reader-writer pair exists to race (contract 2).
|
|
2433
|
+
export function registerSourcePath(projectDir, sessionId, absPath) {
|
|
2434
|
+
try {
|
|
2435
|
+
const dir = scoreDir(projectDir, sessionId);
|
|
2436
|
+
ensureStateDir(projectDir);
|
|
2437
|
+
mkdirSync(dir, { recursive: true });
|
|
2438
|
+
writeFileSync(join(dir, pathKey(absPath)), "", { flag: "a" });
|
|
2439
|
+
return true;
|
|
2440
|
+
} catch {
|
|
2441
|
+
return false;
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
|
|
2445
|
+
// Exact and uncapped: the reminder quotes this number, so a session that wrote
|
|
2446
|
+
// fifty files must not report three.
|
|
2447
|
+
export function entryScore(projectDir, sessionId) {
|
|
2448
|
+
try {
|
|
2449
|
+
return readdirSync(scoreDir(projectDir, sessionId)).length;
|
|
2450
|
+
} catch {
|
|
2451
|
+
return 0;
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
// The single open-story predicate (contract 5), as a pure core: it decides from
|
|
2456
|
+
// already-loaded frontmatter and performs no I/O, so doctor feeds it the
|
|
2457
|
+
// artifact scan it already performs while the hook feeds it a budgeted read —
|
|
2458
|
+
// one definition, and therefore no way for the two to disagree about the same
|
|
2459
|
+
// vault. `planned` is deliberately not open: writing code against a story that
|
|
2460
|
+
// never went through /projectstore:story plan is itself the order being skipped.
|
|
2461
|
+
// Both spellings the vault holds and the kanban maps (statusToColumn): the
|
|
2462
|
+
// SessionStart orientation said "nothing in progress" over six in_progress
|
|
2463
|
+
// stories, and doctor's work-without-story fired beside them (2026-09-05).
|
|
2464
|
+
export function isInProgress(status) {
|
|
2465
|
+
return status === "in-progress" || status === "in_progress" || status === "in progress";
|
|
2466
|
+
}
|
|
2467
|
+
|
|
2468
|
+
export function openStoryFrom(storyFrontmatters) {
|
|
2469
|
+
return (storyFrontmatters || []).some((fm) => fm && isInProgress(fm.status));
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// Every story file in the vault, whatever shape it takes. Deliberately
|
|
2473
|
+
// synchronous: readdir/stat never materialize an iCloud-evicted file, so
|
|
2474
|
+
// enumeration cannot block — only reading contents can. One lister, shared by
|
|
2475
|
+
// both adapters; a parallel async copy would drift.
|
|
2476
|
+
export function listVaultStoryFiles(vaultPath) {
|
|
2477
|
+
const out = [];
|
|
2478
|
+
const epicsDir = join(vaultPath, "epics");
|
|
2479
|
+
let entries;
|
|
2480
|
+
try { entries = readdirSync(epicsDir).sort(); } catch { return out; }
|
|
2481
|
+
for (const e of entries) {
|
|
2482
|
+
for (const s of listEpicStories(join(epicsDir, e))) out.push(s.abs);
|
|
2483
|
+
}
|
|
2484
|
+
return out;
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
// The hook's adapter: tri-state, under a hard budget (contract 6).
|
|
2488
|
+
//
|
|
2489
|
+
// The budget cannot be enforced by checking the clock between files. Node
|
|
2490
|
+
// cannot interrupt a synchronous read, and an iCloud-evicted story does not
|
|
2491
|
+
// fail — it BLOCKS while macOS downloads it, inside a single call. So the reads
|
|
2492
|
+
// are async and raced against a timer: when the timer wins we return "unknown"
|
|
2493
|
+
// with reads still outstanding, and the caller (a short-lived hook process) may
|
|
2494
|
+
// exit — but only because "unknown" suppresses the reminder, so nothing has
|
|
2495
|
+
// been written to stdout. Exiting after a write would truncate it, since
|
|
2496
|
+
// process.exit does not flush pending pipe writes.
|
|
2497
|
+
export async function resolveOpenStory(vaultPath, opts = {}) {
|
|
2498
|
+
const budgetMs = opts.budgetMs ?? 200;
|
|
2499
|
+
const readFile = opts.readFile || ((p) => readFileAsync(p, "utf8"));
|
|
2500
|
+
const files = listVaultStoryFiles(vaultPath);
|
|
2501
|
+
if (!files.length) return false;
|
|
2502
|
+
|
|
2503
|
+
let timer = null;
|
|
2504
|
+
const deadline = new Promise((resolve) => {
|
|
2505
|
+
timer = setTimeout(() => resolve("unknown"), budgetMs);
|
|
2506
|
+
});
|
|
2507
|
+
const scan = (async () => {
|
|
2508
|
+
for (const f of files) {
|
|
2509
|
+
let text;
|
|
2510
|
+
try { text = await readFile(f); } catch { continue; }
|
|
2511
|
+
// String(): the injected reader is a seam, and a caller that forgets an
|
|
2512
|
+
// encoding hands back a Buffer. Coercing here keeps that a non-event.
|
|
2513
|
+
if (openStoryFrom([parseFrontmatter(String(text)).data])) return true;
|
|
2514
|
+
}
|
|
2515
|
+
return false;
|
|
2516
|
+
})();
|
|
2517
|
+
|
|
2518
|
+
try {
|
|
2519
|
+
return await Promise.race([scan, deadline]);
|
|
2520
|
+
} finally {
|
|
2521
|
+
if (timer) clearTimeout(timer);
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// Newest mtime across the vault's markdown, ms, or null for an empty vault.
|
|
2526
|
+
// `stat` does not materialize a dataless file, so unlike a frontmatter sweep
|
|
2527
|
+
// this cannot block on an iCloud download. Advisory by nature: a sync that
|
|
2528
|
+
// rewrites mtimes can only make the caller quieter, never noisier.
|
|
2529
|
+
export function lastVaultActivityMs(vaultPath) {
|
|
2530
|
+
let newest = 0;
|
|
2531
|
+
const walk = (dir) => {
|
|
2532
|
+
let entries;
|
|
2533
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
2534
|
+
for (const e of entries) {
|
|
2535
|
+
if (e.name.startsWith(".")) continue; // .projectstore/, .obsidian/, .git/
|
|
2536
|
+
const p = join(dir, e.name);
|
|
2537
|
+
if (e.isDirectory()) walk(p);
|
|
2538
|
+
else if (e.name.endsWith(".md")) {
|
|
2539
|
+
try {
|
|
2540
|
+
const m = statSync(p).mtimeMs;
|
|
2541
|
+
if (m > newest) newest = m;
|
|
2542
|
+
} catch {}
|
|
2543
|
+
}
|
|
2544
|
+
}
|
|
2545
|
+
};
|
|
2546
|
+
walk(vaultPath);
|
|
2547
|
+
return newest || null;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
export function markerDir(projectDir, sessionId) {
|
|
2551
|
+
return join(stateDir(projectDir), `${sessionId}.fired`);
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
// The sweep runs at most once per session; its verdict — "unknown" included —
|
|
2555
|
+
// is written once and never rewritten, so there is no read-modify-write here
|
|
2556
|
+
// either. `wx` is O_EXCL: a second invocation racing the first simply loses.
|
|
2557
|
+
export function readOpenStoryCache(projectDir, sessionId) {
|
|
2558
|
+
try {
|
|
2559
|
+
const v = readFileSync(join(markerDir(projectDir, sessionId), "open-story"), "utf8").trim();
|
|
2560
|
+
return v === "true" ? true : v === "false" ? false : "unknown";
|
|
2561
|
+
} catch {
|
|
2562
|
+
return null; // no verdict yet — distinct from a cached "unknown"
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
export function writeOpenStoryCache(projectDir, sessionId, value) {
|
|
2567
|
+
try {
|
|
2568
|
+
mkdirSync(markerDir(projectDir, sessionId), { recursive: true });
|
|
2569
|
+
writeFileSync(join(markerDir(projectDir, sessionId), "open-story"), String(value), {
|
|
2570
|
+
flag: "wx",
|
|
2571
|
+
});
|
|
2572
|
+
return true;
|
|
2573
|
+
} catch {
|
|
2574
|
+
return false;
|
|
2575
|
+
}
|
|
2576
|
+
}
|
|
2577
|
+
|
|
2578
|
+
// ── Emitter election (contract 12) ──
|
|
2579
|
+
//
|
|
2580
|
+
// State is three fixed-name marker files in <sid>.fired/: `fired-1`, `fired-2`
|
|
2581
|
+
// and `armed`. Fixed names are the whole mechanism — with process-derived names
|
|
2582
|
+
// every contender's create succeeds and every contender emits.
|
|
2583
|
+
//
|
|
2584
|
+
// `fired-*` are never removed. A discarded conversation CREATES `armed`; the
|
|
2585
|
+
// winning emitter deletes it. Clearing `fired-*` on compaction instead (the
|
|
2586
|
+
// obvious design, and an earlier draft of the spec) makes the cap unreachable:
|
|
2587
|
+
// the directory never holds two, so firings are unbounded at one per
|
|
2588
|
+
// compaction cycle.
|
|
2589
|
+
|
|
2590
|
+
export function firedCount(projectDir, sessionId) {
|
|
2591
|
+
try {
|
|
2592
|
+
return readdirSync(markerDir(projectDir, sessionId))
|
|
2593
|
+
.filter((n) => /^fired-\d+$/.test(n)).length;
|
|
2594
|
+
} catch {
|
|
2595
|
+
return 0;
|
|
2596
|
+
}
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
export function isArmed(projectDir, sessionId) {
|
|
2600
|
+
return existsSync(join(markerDir(projectDir, sessionId), "armed"));
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
// Called from SessionStart on source `compact` or `clear`: the session id
|
|
2604
|
+
// survives but the conversation — and with it the delivered reminder — did not.
|
|
2605
|
+
export function armReminder(projectDir, sessionId) {
|
|
2606
|
+
try {
|
|
2607
|
+
mkdirSync(markerDir(projectDir, sessionId), { recursive: true });
|
|
2608
|
+
writeFileSync(join(markerDir(projectDir, sessionId), "armed"), "", { flag: "wx" });
|
|
2609
|
+
return true;
|
|
2610
|
+
} catch {
|
|
2611
|
+
return false; // already armed; arming twice is not two permissions
|
|
2612
|
+
}
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
export function mayRemind(projectDir, sessionId) {
|
|
2616
|
+
const n = firedCount(projectDir, sessionId);
|
|
2617
|
+
if (n >= 2) return false;
|
|
2618
|
+
return n === 0 || isArmed(projectDir, sessionId);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
// Returns true iff THIS process is the emitter for the current armed context.
|
|
2622
|
+
//
|
|
2623
|
+
// The prize is chosen by state, never by falling through to the next free name.
|
|
2624
|
+
// "try fired-1, on EEXIST try fired-2" reads like the same thing and is not: two
|
|
2625
|
+
// processes that both observe an empty directory would take one name each and
|
|
2626
|
+
// both emit. Deriving the single legal target from the count means exactly one
|
|
2627
|
+
// name is contested per context, and O_EXCL awards it to exactly one caller.
|
|
2628
|
+
// ── The efficacy log (contract 19) ──
|
|
2629
|
+
//
|
|
2630
|
+
// Append-only on the write side; the cap is the reader's job. Two sessions
|
|
2631
|
+
// firing at the same instant can both append safely, where both truncating
|
|
2632
|
+
// would not be safe. `.claude/` is gitignored, so this never travels — doctor
|
|
2633
|
+
// must say "on this machine" or a two-machine maintainer reads a zero as
|
|
2634
|
+
// "the mechanism is broken".
|
|
2635
|
+
|
|
2636
|
+
export const ENTRY_LOG_CAP = 1000;
|
|
2637
|
+
|
|
2638
|
+
// The writer appends to the new path; the reader falls back to the legacy log
|
|
2639
|
+
// while the window is open (contract 1).
|
|
2640
|
+
export function entryLogPath(projectDir) {
|
|
2641
|
+
return layoutPaths(projectDir).entryLog;
|
|
2642
|
+
}
|
|
2643
|
+
export function entryLogReadPath(projectDir) {
|
|
2644
|
+
const p = layoutPaths(projectDir);
|
|
2645
|
+
return pickExisting(p.entryLog, p.legacy.entryLog);
|
|
2646
|
+
}
|
|
2647
|
+
|
|
2648
|
+
export function appendEntryLog(projectDir, record) {
|
|
2649
|
+
try {
|
|
2650
|
+
ensureStateDir(projectDir); // state/ with its own .gitignore
|
|
2651
|
+
appendFileSync(entryLogPath(projectDir), JSON.stringify(record) + "\n", "utf8");
|
|
2652
|
+
return true;
|
|
2653
|
+
} catch {
|
|
2654
|
+
return false;
|
|
2655
|
+
}
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// `kind` discriminates records that share this log. An entry reminder is
|
|
2659
|
+
// unmarked for backward compatibility with logs written before anything else
|
|
2660
|
+
// used the file; every later writer names itself. Callers that mean "entry
|
|
2661
|
+
// reminders" must pass `kind: null` — doctor renders this count as "an entry
|
|
2662
|
+
// reminder fired N time(s)", a sentence a name-offer breadcrumb would falsify.
|
|
2663
|
+
export function readEntryLog(projectDir, { withinDays = 30, kind = undefined } = {}) {
|
|
2664
|
+
let lines;
|
|
2665
|
+
try {
|
|
2666
|
+
lines = readFileSync(entryLogReadPath(projectDir), "utf8").split("\n").filter(Boolean);
|
|
2667
|
+
} catch {
|
|
2668
|
+
return [];
|
|
2669
|
+
}
|
|
2670
|
+
if (lines.length > ENTRY_LOG_CAP) lines = lines.slice(-ENTRY_LOG_CAP);
|
|
2671
|
+
const cutoff = Date.now() - withinDays * 24 * 60 * 60 * 1000;
|
|
2672
|
+
const out = [];
|
|
2673
|
+
for (const l of lines) {
|
|
2674
|
+
try {
|
|
2675
|
+
const r = JSON.parse(l);
|
|
2676
|
+
if (Date.parse(r.at) < cutoff) continue;
|
|
2677
|
+
if (kind !== undefined && (r.kind ?? null) !== kind) continue;
|
|
2678
|
+
out.push(r);
|
|
2679
|
+
} catch {}
|
|
2680
|
+
}
|
|
2681
|
+
return out;
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
// ── The reminder (contracts 4, 15) ──
|
|
2685
|
+
|
|
2686
|
+
export const ENTRY_THRESHOLD = 3;
|
|
2687
|
+
|
|
2688
|
+
// Normative text. Three properties it must keep, whatever the wording: it shows
|
|
2689
|
+
// the evidence (the count), it names the action, and it grants the exit — so a
|
|
2690
|
+
// false positive costs a glance rather than an argument.
|
|
2691
|
+
export function entryReminderText(n) {
|
|
2692
|
+
return [
|
|
2693
|
+
`**projectstore**: this session has written to ${n} source files and no story`,
|
|
2694
|
+
"is in progress. If this is feature-sized work, open it in the vault before",
|
|
2695
|
+
'going further — `/projectstore:story <EPIC> "<title>"`, or',
|
|
2696
|
+
"`/projectstore:epic` if it needs a new one. If it is a one-off fix, carry",
|
|
2697
|
+
"on — this fires once.",
|
|
2698
|
+
].join("\n");
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
export function electEmitter(projectDir, sessionId) {
|
|
2702
|
+
const dir = markerDir(projectDir, sessionId);
|
|
2703
|
+
const n = firedCount(projectDir, sessionId);
|
|
2704
|
+
let target = null;
|
|
2705
|
+
if (n === 0) target = "fired-1";
|
|
2706
|
+
else if (n === 1 && isArmed(projectDir, sessionId)) target = "fired-2";
|
|
2707
|
+
if (!target) return false;
|
|
2708
|
+
try { mkdirSync(dir, { recursive: true }); } catch { return false; }
|
|
2709
|
+
|
|
2710
|
+
// In the ARMED state the arming is the scarce thing, so consuming it must be
|
|
2711
|
+
// the atomic act. Creating fired-2 first and unlinking `armed` afterwards
|
|
2712
|
+
// leaves a window in which a second process still sees count==1 && armed and
|
|
2713
|
+
// targets fired-2 too: both create (different moments, same name is gone —
|
|
2714
|
+
// the loser's create fails, but only if it arrives after; widen the gap and
|
|
2715
|
+
// both win). unlink() succeeding is what elects here, exactly as create()
|
|
2716
|
+
// does in the unarmed state.
|
|
2717
|
+
if (target === "fired-2") {
|
|
2718
|
+
try { unlinkSync(join(dir, "armed")); } catch { return false; }
|
|
2719
|
+
}
|
|
2720
|
+
try {
|
|
2721
|
+
writeFileSync(join(dir, target), "", { flag: "wx" });
|
|
2722
|
+
} catch {
|
|
2723
|
+
return false; // another process took this context's slot
|
|
2724
|
+
}
|
|
2725
|
+
return true;
|
|
2726
|
+
}
|
|
2727
|
+
|
|
2728
|
+
// ─── Session-name anchor (ADR: the settled-anchor offer) ───────────────
|
|
2729
|
+
//
|
|
2730
|
+
// The session name is an ADDRESS: peers route to a session by it, and nothing
|
|
2731
|
+
// a plugin can call sets it — the authoritative value lives in the harness's
|
|
2732
|
+
// memory. So projectstore composes a name and OFFERS it; the person accepts.
|
|
2733
|
+
// (Covering research: "The session name is an address".)
|
|
2734
|
+
//
|
|
2735
|
+
// The whole difficulty is WHEN to speak, and the rule below was selected by
|
|
2736
|
+
// replaying nine recorded sessions, not by taste. Naive "offer on every
|
|
2737
|
+
// epic/story change" fires 19 times in one session. See
|
|
2738
|
+
// tests/fixtures/session-anchor-shapes.json and its drives, which reproduce
|
|
2739
|
+
// the comparison table the ADR cites.
|
|
2740
|
+
//
|
|
2741
|
+
// State for this rule must NOT route through writeSessionState — see the
|
|
2742
|
+
// entry-rule banner above for the mechanism (an unlocked read-modify-write, so
|
|
2743
|
+
// concurrent increments are lost; before 2026-10-03 it also truncated in place
|
|
2744
|
+
// and could erase the ADR-006 statusline pointer). This tally is the
|
|
2745
|
+
// highest-frequency writer in the system, so the hazard is sharper here than
|
|
2746
|
+
// where it is written down. Follow registerSourcePath: one file per key, no
|
|
2747
|
+
// reader-writer pair.
|
|
2748
|
+
//
|
|
2749
|
+
// Two gates that are easy to omit and that the fixtures alone will NOT catch,
|
|
2750
|
+
// because every recorded session is an authoring session: the tally counts
|
|
2751
|
+
// WRITE-family calls only, and MAIN-AGENT calls only. Without the first, a
|
|
2752
|
+
// review session that greps thirty files is offered a name for work it never
|
|
2753
|
+
// did; without the second, parallel subagents (which share one session id)
|
|
2754
|
+
// vote on a name none of them can accept.
|
|
2755
|
+
|
|
2756
|
+
export const ANCHOR_SETTLE = 5; // tally at which a key becomes offerable
|
|
2757
|
+
export const ANCHOR_MARGIN = 10; // lead a challenger needs to take over
|
|
2758
|
+
const ANCHOR_NAME_CELL = 48; // a name is typed by hand; keep it typeable
|
|
2759
|
+
|
|
2760
|
+
// Which cluster an artifact belongs to: its epic, or the document itself.
|
|
2761
|
+
// Folders come from the layout, never from a hard-coded set — a vault that
|
|
2762
|
+
// renames `research/` must not silently stop being nameable.
|
|
2763
|
+
export function anchorKeyOf(rel, layout) {
|
|
2764
|
+
if (typeof rel !== "string" || !rel) return null;
|
|
2765
|
+
const folders = (layout && Array.isArray(layout.folders) ? layout.folders : [])
|
|
2766
|
+
.filter((f) => f && f.path);
|
|
2767
|
+
const epic = folders.find((f) => f.kind === "epic");
|
|
2768
|
+
if (epic && rel.startsWith(epic.path + "/")) {
|
|
2769
|
+
// String ops, never an interpolated RegExp: a layout path is user data, and
|
|
2770
|
+
// `epics.old` would otherwise match `epicsXold/` while a parenthesised path
|
|
2771
|
+
// stopped matching at all — the exact silent failure this function's
|
|
2772
|
+
// layout-driven design exists to avoid.
|
|
2773
|
+
const id = rel.slice(epic.path.length + 1).split("/")[0];
|
|
2774
|
+
if (id) return { key: `epic:${id}`, id, leaf: leafOfStory(rel) };
|
|
2775
|
+
}
|
|
2776
|
+
for (const f of folders) {
|
|
2777
|
+
if (f.kind === "epic") continue;
|
|
2778
|
+
if (rel.startsWith(f.path + "/") && rel.endsWith(".md")) {
|
|
2779
|
+
const base = rel.slice(f.path.length + 1);
|
|
2780
|
+
if (base.includes("/")) continue; // only files directly in the folder
|
|
2781
|
+
// A folder's generated index is not a document: `adr/README.md` would
|
|
2782
|
+
// otherwise settle an anchor and compose the name "readme".
|
|
2783
|
+
if (f.readme && base.toLowerCase() === "readme.md") continue;
|
|
2784
|
+
return { key: `doc:${rel}`, id: base.replace(/\.md$/, ""), leaf: null };
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
return null;
|
|
2788
|
+
}
|
|
2789
|
+
|
|
2790
|
+
function leafOfStory(rel) {
|
|
2791
|
+
const m = rel.match(/\/stories\/(.+)\.md$/);
|
|
2792
|
+
return m ? m[1].replace(/^story-/, "").replace(/^\d+-/, "") : null;
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
export function emptyAnchorState() {
|
|
2796
|
+
return { counts: {}, leaves: {}, incumbent: null, offered: null };
|
|
2797
|
+
}
|
|
2798
|
+
|
|
2799
|
+
// One event folded into the state. Returns the state and, when the anchor
|
|
2800
|
+
// moves, the key that should now be offered — the caller composes the name.
|
|
2801
|
+
// Pure: no clock, no disk, so a fixture replay and the live hook run the same
|
|
2802
|
+
// code path rather than two implementations that agree until they do not.
|
|
2803
|
+
export function foldAnchor(state, hit) {
|
|
2804
|
+
const st = state || emptyAnchorState();
|
|
2805
|
+
if (!hit || !hit.key) return { state: st, offer: null };
|
|
2806
|
+
const counts = { ...st.counts, [hit.key]: (st.counts[hit.key] || 0) + 1 };
|
|
2807
|
+
const leaves = { ...st.leaves };
|
|
2808
|
+
if (hit.leaf) {
|
|
2809
|
+
const per = { ...(leaves[hit.key] || {}) };
|
|
2810
|
+
per[hit.leaf] = (per[hit.leaf] || 0) + 1;
|
|
2811
|
+
leaves[hit.key] = per;
|
|
2812
|
+
}
|
|
2813
|
+
let incumbent = st.incumbent;
|
|
2814
|
+
let offered = st.offered;
|
|
2815
|
+
let offer = null;
|
|
2816
|
+
const top = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
|
2817
|
+
if (top && top[1] >= ANCHOR_SETTLE) {
|
|
2818
|
+
const take = incumbent === null
|
|
2819
|
+
? true
|
|
2820
|
+
: top[0] !== incumbent && top[1] >= (counts[incumbent] || 0) + ANCHOR_MARGIN;
|
|
2821
|
+
if (take && top[0] !== incumbent) {
|
|
2822
|
+
incumbent = top[0];
|
|
2823
|
+
// A moved anchor is not automatically a new NAME. Two documents can share
|
|
2824
|
+
// a basename across folders, and an A→B→A pivot returns to a name already
|
|
2825
|
+
// offered — in both cases repeating it is noise that looks like a bug.
|
|
2826
|
+
// Suppress here rather than at the call site: the invariant "the same name
|
|
2827
|
+
// is never offered twice running" belongs to the rule, not to one consumer.
|
|
2828
|
+
const name = composeAnchorName({ counts, leaves, incumbent, offered }, incumbent);
|
|
2829
|
+
if (name && name !== offered) {
|
|
2830
|
+
offer = { key: incumbent, name };
|
|
2831
|
+
offered = name;
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
return { state: { counts, leaves, incumbent, offered }, offer };
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
// epic anchors read as "<epic-id>-<its most-written story>"; a document anchor
|
|
2839
|
+
// is its own slug. Truncation is on a word boundary — a name cut mid-word
|
|
2840
|
+
// reads as a typo, and this one gets typed back by a person.
|
|
2841
|
+
export function composeAnchorName(state, key) {
|
|
2842
|
+
const st = state || emptyAnchorState();
|
|
2843
|
+
const k = key || st.incumbent;
|
|
2844
|
+
if (!k) return null;
|
|
2845
|
+
const [kind, rest] = [k.slice(0, k.indexOf(":")), k.slice(k.indexOf(":") + 1)];
|
|
2846
|
+
let name;
|
|
2847
|
+
if (kind === "epic") {
|
|
2848
|
+
const per = st.leaves[k] || {};
|
|
2849
|
+
const top = Object.entries(per).sort((a, b) => b[1] - a[1])[0];
|
|
2850
|
+
name = rest.toLowerCase() + (top ? `-${top[0]}` : "");
|
|
2851
|
+
} else {
|
|
2852
|
+
name = rest.replace(/^.*\//, "").replace(/\.md$/, "");
|
|
2853
|
+
}
|
|
2854
|
+
// slugify transliterates (Cyrillic included) — without it a non-ASCII title
|
|
2855
|
+
// composed to null and burned the incumbent slot, leaving the session
|
|
2856
|
+
// permanently nameless with nothing to show for it.
|
|
2857
|
+
name = slugify(name);
|
|
2858
|
+
if (name.length <= ANCHOR_NAME_CELL) return name || null;
|
|
2859
|
+
const words = name.split("-");
|
|
2860
|
+
let out = "";
|
|
2861
|
+
for (const w of words) {
|
|
2862
|
+
if (out && (out + "-" + w).length > ANCHOR_NAME_CELL) break;
|
|
2863
|
+
// One exception to the word-boundary rule, and the only one: a single word
|
|
2864
|
+
// longer than the cell has no boundary to cut on, so it is cut anyway.
|
|
2865
|
+
out = out ? out + "-" + w : w.slice(0, ANCHOR_NAME_CELL);
|
|
2866
|
+
}
|
|
2867
|
+
return out || null;
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
// ─── Session-name anchor state (on-disk half) ─────────────────────────
|
|
2871
|
+
//
|
|
2872
|
+
// Same discipline as the entry-rule score directory above, and for the same
|
|
2873
|
+
// reason stated there: NOTHING here routes through writeSessionState. A tally
|
|
2874
|
+
// incremented on every vault write is the highest-frequency writer in this
|
|
2875
|
+
// system, and that function's unlocked read-modify-write would lose its
|
|
2876
|
+
// increments to every concurrent writer — the last rename wins.
|
|
2877
|
+
//
|
|
2878
|
+
// So a tally is a file that only ever grows by one byte: O_APPEND of a single
|
|
2879
|
+
// byte is atomic, the count is the file's size, and no reader-writer pair
|
|
2880
|
+
// exists. Concurrent subagents cannot lose each other's increment.
|
|
2881
|
+
//
|
|
2882
|
+
// The incumbent/last-offered record is the one small piece that must be read
|
|
2883
|
+
// back, and it lives in its OWN file for exactly that reason. It is written at
|
|
2884
|
+
// most once per offer (once or twice a session), atomically since 2026-10-03:
|
|
2885
|
+
// two writers with different payloads used to leave a JSON with the longer
|
|
2886
|
+
// one's tail, which reads as an empty record and forgets `declined`. Its worst
|
|
2887
|
+
// failure now is a lost write — one duplicate offer and nothing else. Putting
|
|
2888
|
+
// it in the shared session state would trade that for a blank statusline.
|
|
2889
|
+
|
|
2890
|
+
export function anchorDir(projectDir, sessionId) {
|
|
2891
|
+
return join(stateDir(projectDir), `${sessionId}.anchor`);
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
function anchorSlot(key, leaf) {
|
|
2895
|
+
const h = createHash("sha1").update(leaf ? `${key}\u0000${leaf}` : key).digest("hex").slice(0, 16);
|
|
2896
|
+
return leaf ? `l${h}` : `k${h}`;
|
|
2897
|
+
}
|
|
2898
|
+
|
|
2899
|
+
// One byte per event, plus a sidecar naming the key. The sidecar is written
|
|
2900
|
+
// with identical bytes every time, so a concurrent rewrite is a no-op rather
|
|
2901
|
+
// than a race.
|
|
2902
|
+
export function bumpAnchor(projectDir, sessionId, key, leaf = null) {
|
|
2903
|
+
try {
|
|
2904
|
+
const dir = anchorDir(projectDir, sessionId);
|
|
2905
|
+
ensureStateDir(projectDir);
|
|
2906
|
+
mkdirSync(dir, { recursive: true });
|
|
2907
|
+
const slot = anchorSlot(key, leaf);
|
|
2908
|
+
// Create-only. The default flag is O_CREAT|O_TRUNC, so rewriting "the same
|
|
2909
|
+
// bytes" is still observably empty to a concurrent reader — measured at ~4%
|
|
2910
|
+
// of reads losing an entire key, which is the very O_TRUNC failure the
|
|
2911
|
+
// entry-rule banner above condemns. `wx` throws EEXIST after the first
|
|
2912
|
+
// bump; the inner catch is load-bearing, or the tally append below would be
|
|
2913
|
+
// skipped along with it.
|
|
2914
|
+
try {
|
|
2915
|
+
writeFileSync(join(dir, `${slot}.name`), leaf ? `${key}\n${leaf}` : key, { flag: "wx" });
|
|
2916
|
+
} catch {}
|
|
2917
|
+
writeFileSync(join(dir, slot), "x", { flag: "a" });
|
|
2918
|
+
return true;
|
|
2919
|
+
} catch {
|
|
2920
|
+
return false;
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2924
|
+
// Rebuild the pure rule's state shape from disk. Sizes are the tallies, so a
|
|
2925
|
+
// partially-written sidecar loses one key's name rather than the whole tally.
|
|
2926
|
+
export function readAnchorState(projectDir, sessionId) {
|
|
2927
|
+
const st = emptyAnchorState();
|
|
2928
|
+
let names;
|
|
2929
|
+
try {
|
|
2930
|
+
names = readdirSync(anchorDir(projectDir, sessionId));
|
|
2931
|
+
} catch {
|
|
2932
|
+
return st;
|
|
2933
|
+
}
|
|
2934
|
+
const dir = anchorDir(projectDir, sessionId);
|
|
2935
|
+
for (const f of names) {
|
|
2936
|
+
if (!f.endsWith(".name")) continue;
|
|
2937
|
+
const slot = f.slice(0, -5);
|
|
2938
|
+
let raw, n;
|
|
2939
|
+
try {
|
|
2940
|
+
raw = readFileSync(join(dir, f), "utf8");
|
|
2941
|
+
n = statSync(join(dir, slot)).size;
|
|
2942
|
+
} catch { continue; }
|
|
2943
|
+
if (!raw || !n) continue;
|
|
2944
|
+
if (slot.startsWith("k")) {
|
|
2945
|
+
st.counts[raw] = n;
|
|
2946
|
+
} else {
|
|
2947
|
+
const nl = raw.indexOf("\n");
|
|
2948
|
+
if (nl < 0) continue;
|
|
2949
|
+
const key = raw.slice(0, nl), leaf = raw.slice(nl + 1);
|
|
2950
|
+
st.leaves[key] = { ...(st.leaves[key] || {}), [leaf]: n };
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
const rec = readAnchorOffer(projectDir, sessionId);
|
|
2954
|
+
st.incumbent = rec.incumbent;
|
|
2955
|
+
st.offered = rec.offered;
|
|
2956
|
+
return st;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
function anchorOfferPath(projectDir, sessionId) {
|
|
2960
|
+
return join(stateDir(projectDir), `${sessionId}.anchor.json`);
|
|
2961
|
+
}
|
|
2962
|
+
|
|
2963
|
+
export function readAnchorOffer(projectDir, sessionId) {
|
|
2964
|
+
try {
|
|
2965
|
+
const d = JSON.parse(readFileSync(anchorOfferPath(projectDir, sessionId), "utf8"));
|
|
2966
|
+
return {
|
|
2967
|
+
incumbent: typeof d.incumbent === "string" ? d.incumbent : null,
|
|
2968
|
+
offered: typeof d.offered === "string" ? d.offered : null,
|
|
2969
|
+
// A name the PERSON chose is not ours to talk over. Recorded when we see
|
|
2970
|
+
// the session already carrying a name we did not compose.
|
|
2971
|
+
declined: Array.isArray(d.declined) ? d.declined : [],
|
|
2972
|
+
// Every name we have composed this session. A session almost always
|
|
2973
|
+
// arrives already wearing a harness-assigned name, so "the current name
|
|
2974
|
+
// is not our last offer" cannot mean "the person chose it" — that reading
|
|
2975
|
+
// silenced the feature permanently for every real session.
|
|
2976
|
+
offers: Array.isArray(d.offers) ? d.offers : [],
|
|
2977
|
+
};
|
|
2978
|
+
} catch {
|
|
2979
|
+
// Every field the success path returns, or a caller reading one that only
|
|
2980
|
+
// exists on the happy path throws — and a hook swallows that into silence,
|
|
2981
|
+
// which looks exactly like the mechanism deciding to stay quiet.
|
|
2982
|
+
return { incumbent: null, offered: null, declined: [], offers: [] };
|
|
2983
|
+
}
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2986
|
+
export function writeAnchorOffer(projectDir, sessionId, rec) {
|
|
2987
|
+
try {
|
|
2988
|
+
ensureStateDir(projectDir);
|
|
2989
|
+
writeFileAtomic(anchorOfferPath(projectDir, sessionId), JSON.stringify(rec), { sweep: false });
|
|
2990
|
+
return true;
|
|
2991
|
+
} catch {
|
|
2992
|
+
return false;
|
|
2993
|
+
}
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
// ─── Session-name offer (delivery half) ───────────────────────────────
|
|
2997
|
+
|
|
2998
|
+
// Names live peers already hold. Read from the harness's own registry of live
|
|
2999
|
+
// sessions, which is how names are arbitrated machine-wide: a duplicate is not
|
|
3000
|
+
// merely confusing, it is a second session answering to one address.
|
|
3001
|
+
//
|
|
3002
|
+
// This sees ACCEPTED names only. Two sessions that settle on the same anchor
|
|
3003
|
+
// within moments of each other both pass this check and both offer the same
|
|
3004
|
+
// name — the race is narrowed, not closed, and the ADR says so.
|
|
3005
|
+
export function liveSessionNames(selfSessionId, dir = null) {
|
|
3006
|
+
const root = dir || join(claudeHome(), "sessions");
|
|
3007
|
+
const out = [];
|
|
3008
|
+
let files;
|
|
3009
|
+
try { files = readdirSync(root); } catch { return out; }
|
|
3010
|
+
for (const f of files) {
|
|
3011
|
+
if (!f.endsWith(".json")) continue;
|
|
3012
|
+
try {
|
|
3013
|
+
const d = JSON.parse(readFileSync(join(root, f), "utf8"));
|
|
3014
|
+
if (!d || typeof d.name !== "string" || !d.name) continue;
|
|
3015
|
+
// Match on session id, never on pid: this process is the hook's, not the
|
|
3016
|
+
// session's, and its parent is not reliably the session either.
|
|
3017
|
+
if (selfSessionId && d.sessionId === selfSessionId) continue;
|
|
3018
|
+
out.push({ name: d.name, sessionId: d.sessionId || null });
|
|
3019
|
+
} catch {}
|
|
3020
|
+
}
|
|
3021
|
+
return out;
|
|
3022
|
+
}
|
|
3023
|
+
|
|
3024
|
+
// What this session is currently called, if the harness has recorded a name.
|
|
3025
|
+
export function ownSessionName(selfSessionId, dir = null) {
|
|
3026
|
+
const root = dir || join(claudeHome(), "sessions");
|
|
3027
|
+
let files;
|
|
3028
|
+
try { files = readdirSync(root); } catch { return null; }
|
|
3029
|
+
for (const f of files) {
|
|
3030
|
+
if (!f.endsWith(".json")) continue;
|
|
3031
|
+
try {
|
|
3032
|
+
const d = JSON.parse(readFileSync(join(root, f), "utf8"));
|
|
3033
|
+
if (d && d.sessionId === selfSessionId && typeof d.name === "string" && d.name) return d.name;
|
|
3034
|
+
} catch {}
|
|
3035
|
+
}
|
|
3036
|
+
return null;
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
// The offer, or null when it must stay silent. Every suppression here is a
|
|
3040
|
+
// decision the ADR names; none of them is an implementation detail.
|
|
3041
|
+
export function sessionNameOffer(name, { peers = [], current = null, declined = [] } = {}) {
|
|
3042
|
+
if (!name) return null;
|
|
3043
|
+
// Never talk over a name the person chose. `declined` carries names we have
|
|
3044
|
+
// seen the session wear that we did not compose.
|
|
3045
|
+
if (current && current !== name && declined.includes(current)) return null;
|
|
3046
|
+
if (current === name) return null; // already wearing it
|
|
3047
|
+
const taken = new Set(peers.map((p) => p && p.name).filter(Boolean));
|
|
3048
|
+
if (!taken.has(name)) return { name, qualified: false };
|
|
3049
|
+
// Taken. Qualify rather than skip: the anchor is still the honest answer, and
|
|
3050
|
+
// a session that stays unnamed because a peer got there first is the worse
|
|
3051
|
+
// outcome. Two suffixes is the whole ladder — a third collision means the
|
|
3052
|
+
// name is not discriminating and silence is the better answer.
|
|
3053
|
+
for (const suffix of ["-2", "-3"]) {
|
|
3054
|
+
if (!taken.has(name + suffix)) return { name: name + suffix, qualified: true };
|
|
3055
|
+
}
|
|
3056
|
+
return null;
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
export function sessionNameOfferText(offer) {
|
|
3060
|
+
if (!offer || !offer.name) return null;
|
|
3061
|
+
return offer.qualified
|
|
3062
|
+
? `projectstore: this session looks like "${offer.name}" (a peer holds the unqualified name) — /rename ${offer.name}`
|
|
3063
|
+
: `projectstore: this session looks like "${offer.name}" — /rename ${offer.name}`;
|
|
3064
|
+
}
|
|
3065
|
+
|
|
3066
|
+
// ─── Frontmatter parsing (minimal) ─────────────────────────────────────
|
|
3067
|
+
|
|
3068
|
+
export function parseFrontmatter(md) {
|
|
3069
|
+
const m = md.match(/^---\n([\s\S]*?)\n---/);
|
|
3070
|
+
if (!m) return { data: {}, body: md };
|
|
3071
|
+
const data = {};
|
|
3072
|
+
for (const line of m[1].split("\n")) {
|
|
3073
|
+
const kv = line.match(/^(\w+):\s*(.*)$/);
|
|
3074
|
+
if (!kv) continue;
|
|
3075
|
+
let v = kv[2].trim();
|
|
3076
|
+
if (v === "null") v = null;
|
|
3077
|
+
else if (v.startsWith('"') && v.endsWith('"')) {
|
|
3078
|
+
// JSON.parse round-trips escaped scalars from renderTemplate's _json
|
|
3079
|
+
// form (titles containing quotes); fall back to the bare strip.
|
|
3080
|
+
try { v = JSON.parse(v); } catch { v = v.slice(1, -1); }
|
|
3081
|
+
}
|
|
3082
|
+
data[kv[1]] = v;
|
|
3083
|
+
}
|
|
3084
|
+
return { data, body: md.slice(m[0].length) };
|
|
3085
|
+
}
|