projectstore-codex 0.0.1 → 0.28.2

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.
Files changed (184) hide show
  1. package/.codex-plugin/plugin.json +48 -0
  2. package/README.md +15 -7
  3. package/bin/projectstore-codex.mjs +88 -0
  4. package/hooks/hooks.json +59 -0
  5. package/node_modules/projectstore/.claude-plugin/marketplace.json +40 -0
  6. package/node_modules/projectstore/.claude-plugin/plugin.json +23 -0
  7. package/node_modules/projectstore/.mcp.json +14 -0
  8. package/node_modules/projectstore/AGENTS.md +26 -0
  9. package/node_modules/projectstore/LICENSE +21 -0
  10. package/node_modules/projectstore/README.md +284 -0
  11. package/node_modules/projectstore/agents/archaeologist.md +76 -0
  12. package/node_modules/projectstore/agents/clerk.md +93 -0
  13. package/node_modules/projectstore/agents/critic.md +94 -0
  14. package/node_modules/projectstore/agents/librarian.md +81 -0
  15. package/node_modules/projectstore/agents/planner.md +80 -0
  16. package/node_modules/projectstore/agents/reviewer.md +98 -0
  17. package/node_modules/projectstore/bin/projectstore.mjs +7 -0
  18. package/node_modules/projectstore/commands/adr.md +57 -0
  19. package/node_modules/projectstore/commands/agents.md +180 -0
  20. package/node_modules/projectstore/commands/bind.md +128 -0
  21. package/node_modules/projectstore/commands/codemap.md +50 -0
  22. package/node_modules/projectstore/commands/concept.md +17 -0
  23. package/node_modules/projectstore/commands/doctor.md +166 -0
  24. package/node_modules/projectstore/commands/epic.md +40 -0
  25. package/node_modules/projectstore/commands/graph.md +56 -0
  26. package/node_modules/projectstore/commands/kanban.md +40 -0
  27. package/node_modules/projectstore/commands/meeting.md +17 -0
  28. package/node_modules/projectstore/commands/reconcile.md +73 -0
  29. package/node_modules/projectstore/commands/research.md +17 -0
  30. package/node_modules/projectstore/commands/review.md +89 -0
  31. package/node_modules/projectstore/commands/runbook.md +17 -0
  32. package/node_modules/projectstore/commands/scaffold.md +23 -0
  33. package/node_modules/projectstore/commands/search.md +22 -0
  34. package/node_modules/projectstore/commands/spec.md +91 -0
  35. package/node_modules/projectstore/commands/status.md +27 -0
  36. package/node_modules/projectstore/commands/statusline.md +46 -0
  37. package/node_modules/projectstore/commands/story.md +113 -0
  38. package/node_modules/projectstore/docs/extending.md +172 -0
  39. package/node_modules/projectstore/docs/getting-started.md +133 -0
  40. package/node_modules/projectstore/docs/harnesses.md +176 -0
  41. package/node_modules/projectstore/docs/how-it-works.md +263 -0
  42. package/node_modules/projectstore/docs/images/loop-light.svg +94 -0
  43. package/node_modules/projectstore/docs/images/loop.svg +93 -0
  44. package/node_modules/projectstore/docs/images/statusline-hud.png +0 -0
  45. package/node_modules/projectstore/docs/images/team-light.svg +79 -0
  46. package/node_modules/projectstore/docs/images/team.svg +79 -0
  47. package/node_modules/projectstore/harnesses/claude-code.json +483 -0
  48. package/node_modules/projectstore/harnesses/codex.json +332 -0
  49. package/node_modules/projectstore/hooks/hooks.json +59 -0
  50. package/node_modules/projectstore/hooks/pre-compact.mjs +121 -0
  51. package/node_modules/projectstore/hooks/session-rules.mjs +63 -0
  52. package/node_modules/projectstore/hooks/session-start.mjs +301 -0
  53. package/node_modules/projectstore/hooks/session-stop.mjs +84 -0
  54. package/node_modules/projectstore/package.json +70 -0
  55. package/node_modules/projectstore/scaffold/checklists.json +88 -0
  56. package/node_modules/projectstore/scaffold/headings.json +171 -0
  57. package/node_modules/projectstore/scaffold/layouts/engineering.json +85 -0
  58. package/node_modules/projectstore/scripts/binding.mjs +165 -0
  59. package/node_modules/projectstore/scripts/build-adapters.mjs +264 -0
  60. package/node_modules/projectstore/scripts/cli.mjs +595 -0
  61. package/node_modules/projectstore/scripts/codemap.mjs +99 -0
  62. package/node_modules/projectstore/scripts/diff-refs.mjs +127 -0
  63. package/node_modules/projectstore/scripts/doctor.mjs +2127 -0
  64. package/node_modules/projectstore/scripts/draft.mjs +261 -0
  65. package/node_modules/projectstore/scripts/graph.mjs +219 -0
  66. package/node_modules/projectstore/scripts/harness.mjs +608 -0
  67. package/node_modules/projectstore/scripts/install-harness.mjs +1387 -0
  68. package/node_modules/projectstore/scripts/kanban.mjs +174 -0
  69. package/node_modules/projectstore/scripts/lib.mjs +3085 -0
  70. package/node_modules/projectstore/scripts/mcp.mjs +391 -0
  71. package/node_modules/projectstore/scripts/portable-registration.mjs +198 -0
  72. package/node_modules/projectstore/scripts/provenance.mjs +375 -0
  73. package/node_modules/projectstore/scripts/query.mjs +490 -0
  74. package/node_modules/projectstore/scripts/reconcile.mjs +422 -0
  75. package/node_modules/projectstore/scripts/statusline-launcher.mjs +141 -0
  76. package/node_modules/projectstore/scripts/statusline.mjs +253 -0
  77. package/node_modules/projectstore/scripts/story-section.mjs +209 -0
  78. package/node_modules/projectstore/scripts/surfaces.mjs +421 -0
  79. package/node_modules/projectstore/scripts/tokens.mjs +449 -0
  80. package/node_modules/projectstore/scripts/touch-session.mjs +336 -0
  81. package/node_modules/projectstore/scripts/version-guard.mjs +261 -0
  82. package/node_modules/projectstore/scripts/worktree.mjs +109 -0
  83. package/node_modules/projectstore/skills/projectstore-decision-detector/SKILL.md +40 -0
  84. package/node_modules/projectstore/skills/projectstore-peer-reviewer/SKILL.md +38 -0
  85. package/node_modules/projectstore/skills/projectstore-story-completion/SKILL.md +50 -0
  86. package/node_modules/projectstore/skills/projectstore-vault-communication/SKILL.md +96 -0
  87. package/node_modules/projectstore/templates/claude-md-block.md.tmpl +26 -0
  88. package/node_modules/projectstore/templates/de/adr.md.tmpl +67 -0
  89. package/node_modules/projectstore/templates/de/concept.md.tmpl +43 -0
  90. package/node_modules/projectstore/templates/de/epic.md.tmpl +59 -0
  91. package/node_modules/projectstore/templates/de/folder-readme.md.tmpl +14 -0
  92. package/node_modules/projectstore/templates/de/kanban.md.tmpl +36 -0
  93. package/node_modules/projectstore/templates/de/meeting.md.tmpl +38 -0
  94. package/node_modules/projectstore/templates/de/research.md.tmpl +47 -0
  95. package/node_modules/projectstore/templates/de/runbook.md.tmpl +53 -0
  96. package/node_modules/projectstore/templates/de/spec.md.tmpl +64 -0
  97. package/node_modules/projectstore/templates/de/story.md.tmpl +76 -0
  98. package/node_modules/projectstore/templates/de/strings.json +6 -0
  99. package/node_modules/projectstore/templates/en/adr.md.tmpl +67 -0
  100. package/node_modules/projectstore/templates/en/concept.md.tmpl +43 -0
  101. package/node_modules/projectstore/templates/en/epic.md.tmpl +59 -0
  102. package/node_modules/projectstore/templates/en/folder-readme.md.tmpl +14 -0
  103. package/node_modules/projectstore/templates/en/kanban.md.tmpl +36 -0
  104. package/node_modules/projectstore/templates/en/meeting.md.tmpl +38 -0
  105. package/node_modules/projectstore/templates/en/research.md.tmpl +47 -0
  106. package/node_modules/projectstore/templates/en/runbook.md.tmpl +53 -0
  107. package/node_modules/projectstore/templates/en/spec.md.tmpl +64 -0
  108. package/node_modules/projectstore/templates/en/story.md.tmpl +76 -0
  109. package/node_modules/projectstore/templates/en/strings.json +6 -0
  110. package/node_modules/projectstore/templates/es/adr.md.tmpl +67 -0
  111. package/node_modules/projectstore/templates/es/concept.md.tmpl +43 -0
  112. package/node_modules/projectstore/templates/es/epic.md.tmpl +59 -0
  113. package/node_modules/projectstore/templates/es/folder-readme.md.tmpl +14 -0
  114. package/node_modules/projectstore/templates/es/kanban.md.tmpl +36 -0
  115. package/node_modules/projectstore/templates/es/meeting.md.tmpl +38 -0
  116. package/node_modules/projectstore/templates/es/research.md.tmpl +47 -0
  117. package/node_modules/projectstore/templates/es/runbook.md.tmpl +53 -0
  118. package/node_modules/projectstore/templates/es/spec.md.tmpl +64 -0
  119. package/node_modules/projectstore/templates/es/story.md.tmpl +76 -0
  120. package/node_modules/projectstore/templates/es/strings.json +6 -0
  121. package/node_modules/projectstore/templates/fr/adr.md.tmpl +67 -0
  122. package/node_modules/projectstore/templates/fr/concept.md.tmpl +43 -0
  123. package/node_modules/projectstore/templates/fr/epic.md.tmpl +59 -0
  124. package/node_modules/projectstore/templates/fr/folder-readme.md.tmpl +14 -0
  125. package/node_modules/projectstore/templates/fr/kanban.md.tmpl +36 -0
  126. package/node_modules/projectstore/templates/fr/meeting.md.tmpl +38 -0
  127. package/node_modules/projectstore/templates/fr/research.md.tmpl +47 -0
  128. package/node_modules/projectstore/templates/fr/runbook.md.tmpl +53 -0
  129. package/node_modules/projectstore/templates/fr/spec.md.tmpl +64 -0
  130. package/node_modules/projectstore/templates/fr/story.md.tmpl +76 -0
  131. package/node_modules/projectstore/templates/fr/strings.json +6 -0
  132. package/node_modules/projectstore/templates/ru/adr.md.tmpl +67 -0
  133. package/node_modules/projectstore/templates/ru/concept.md.tmpl +43 -0
  134. package/node_modules/projectstore/templates/ru/epic.md.tmpl +59 -0
  135. package/node_modules/projectstore/templates/ru/folder-readme.md.tmpl +14 -0
  136. package/node_modules/projectstore/templates/ru/kanban.md.tmpl +36 -0
  137. package/node_modules/projectstore/templates/ru/meeting.md.tmpl +38 -0
  138. package/node_modules/projectstore/templates/ru/research.md.tmpl +47 -0
  139. package/node_modules/projectstore/templates/ru/runbook.md.tmpl +53 -0
  140. package/node_modules/projectstore/templates/ru/spec.md.tmpl +64 -0
  141. package/node_modules/projectstore/templates/ru/story.md.tmpl +76 -0
  142. package/node_modules/projectstore/templates/ru/strings.json +6 -0
  143. package/node_modules/projectstore/templates/zh/adr.md.tmpl +67 -0
  144. package/node_modules/projectstore/templates/zh/concept.md.tmpl +43 -0
  145. package/node_modules/projectstore/templates/zh/epic.md.tmpl +59 -0
  146. package/node_modules/projectstore/templates/zh/folder-readme.md.tmpl +14 -0
  147. package/node_modules/projectstore/templates/zh/kanban.md.tmpl +36 -0
  148. package/node_modules/projectstore/templates/zh/meeting.md.tmpl +38 -0
  149. package/node_modules/projectstore/templates/zh/research.md.tmpl +47 -0
  150. package/node_modules/projectstore/templates/zh/runbook.md.tmpl +53 -0
  151. package/node_modules/projectstore/templates/zh/spec.md.tmpl +64 -0
  152. package/node_modules/projectstore/templates/zh/story.md.tmpl +76 -0
  153. package/node_modules/projectstore/templates/zh/strings.json +6 -0
  154. package/package.json +35 -14
  155. package/skills/projectstore-adr/SKILL.md +76 -0
  156. package/skills/projectstore-agents/SKILL.md +50 -0
  157. package/skills/projectstore-archaeologist/SKILL.md +109 -0
  158. package/skills/projectstore-bind/SKILL.md +44 -0
  159. package/skills/projectstore-clerk/SKILL.md +126 -0
  160. package/skills/projectstore-codemap/SKILL.md +69 -0
  161. package/skills/projectstore-concept/SKILL.md +36 -0
  162. package/skills/projectstore-critic/SKILL.md +127 -0
  163. package/skills/projectstore-decision-detector/SKILL.md +59 -0
  164. package/skills/projectstore-doctor/SKILL.md +33 -0
  165. package/skills/projectstore-epic/SKILL.md +59 -0
  166. package/skills/projectstore-graph/SKILL.md +75 -0
  167. package/skills/projectstore-kanban/SKILL.md +60 -0
  168. package/skills/projectstore-librarian/SKILL.md +114 -0
  169. package/skills/projectstore-meeting/SKILL.md +36 -0
  170. package/skills/projectstore-peer-reviewer/SKILL.md +57 -0
  171. package/skills/projectstore-planner/SKILL.md +113 -0
  172. package/skills/projectstore-reconcile/SKILL.md +92 -0
  173. package/skills/projectstore-research/SKILL.md +36 -0
  174. package/skills/projectstore-review/SKILL.md +108 -0
  175. package/skills/projectstore-reviewer/SKILL.md +131 -0
  176. package/skills/projectstore-runbook/SKILL.md +36 -0
  177. package/skills/projectstore-scaffold/SKILL.md +42 -0
  178. package/skills/projectstore-search/SKILL.md +41 -0
  179. package/skills/projectstore-spec/SKILL.md +110 -0
  180. package/skills/projectstore-status/SKILL.md +47 -0
  181. package/skills/projectstore-statusline/SKILL.md +29 -0
  182. package/skills/projectstore-story/SKILL.md +132 -0
  183. package/skills/projectstore-story-completion/SKILL.md +69 -0
  184. 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
+ }