sonorance 0.1.0-beta.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +174 -0
  2. package/README.md +103 -0
  3. package/build/icon.png +0 -0
  4. package/package.json +86 -0
  5. package/skill/SKILL.md +41 -0
  6. package/skill/scripts/sonorance.mjs +31 -0
  7. package/src/azure-monitor.mjs +221 -0
  8. package/src/cli.mjs +315 -0
  9. package/src/comment-client.mjs +75 -0
  10. package/src/engine-default.mjs +136 -0
  11. package/src/feedback.mjs +151 -0
  12. package/src/gitignore.mjs +27 -0
  13. package/src/grammar.mjs +55 -0
  14. package/src/identity.mjs +126 -0
  15. package/src/otlp.mjs +166 -0
  16. package/src/plugins/deliberate/contribute.mjs +28 -0
  17. package/src/plugins/deliberate/domain.mjs +40 -0
  18. package/src/plugins/deliberate/frontmatter.mjs +85 -0
  19. package/src/plugins/deliberate/gitignore.mjs +21 -0
  20. package/src/plugins/deliberate/kinds.mjs +44 -0
  21. package/src/plugins/deliberate/markdown.mjs +42 -0
  22. package/src/plugins/deliberate/paths.mjs +245 -0
  23. package/src/plugins/deliberate/stages.mjs +27 -0
  24. package/src/plugins/deliberate/vault.mjs +1043 -0
  25. package/src/plugins.mjs +91 -0
  26. package/src/release-config.mjs +2 -0
  27. package/src/scrubber.mjs +64 -0
  28. package/src/server/index.mjs +993 -0
  29. package/src/sources.mjs +80 -0
  30. package/src/telemetry-schema.mjs +187 -0
  31. package/src/telemetry.mjs +390 -0
  32. package/src/ui/active-line.mjs +42 -0
  33. package/src/ui/app.js +3553 -0
  34. package/src/ui/at-mention.mjs +67 -0
  35. package/src/ui/comments-plugin.mjs +107 -0
  36. package/src/ui/diff-plugin.mjs +73 -0
  37. package/src/ui/editor.mjs +210 -0
  38. package/src/ui/index.html +1723 -0
  39. package/src/ui/md.mjs +233 -0
  40. package/src/ui/paste-md.mjs +54 -0
  41. package/src/ui/shell.html +1374 -0
  42. package/src/ui/slash.mjs +122 -0
  43. package/src/vault-registry.mjs +150 -0
@@ -0,0 +1,1043 @@
1
+ /**
2
+ * vault.mjs — the file-backed store. Replaces the old SQLite `Store`: the source
3
+ * of truth is a folder of Markdown/JSON per project (a "vault"). There is no database.
4
+ *
5
+ * It keeps (most of) the old `Store` method surface so the pipeline, server and
6
+ * CLI barely change — but reads/writes plain files instead of rows, and holds a
7
+ * small in-memory index (caseId → folder) so `getCase`/`listStages` stay O(1).
8
+ * A project's data:
9
+ *
10
+ * <vault>/deliberate/context/product.md the project context as markdown (host-written)
11
+ * <vault>/deliberate/context/competitors.md per-competitor official monitoring sources
12
+ * <vault>/deliberate/context/ecosystem.md per-player (dependency/complement/channel/mover) monitoring sources
13
+ * <vault>/.sonorance/config.json the per-vault identity {id,name,repo,created_at} + gate
14
+ * default + active case + workbench state (COMMITTED)
15
+ * <vault>/.sonorance/sources.md the project's grounding sources — a hand-editable Markdown
16
+ * list (COMMITTED)
17
+ * <vault>/.sonorance/plugins.json the vault's enabled plugins (e.g. Deliberate) — the flavor (COMMITTED)
18
+ * <vault>/.sonorance/local/comments.jsonl project-level in-record comments (annotations on
19
+ * ANY file — cases, briefs, or other — each line records the annotated file's path; gitignored)
20
+ * <vault>/deliberate/cases/<YYYY-MM-DD-slug>/analysis.md the ONE file per case: frontmatter
21
+ * {id,case,state,run_token,score?,created,updated,prompt?,<stage>_…} + the decision record —
22
+ * `# Title`, a 1–2 sentence case summary lede, `## Key highlights`, links to the score /
23
+ * one-pager / prototype companions, then a `## <Stage>` section per funnel stage
24
+ * <vault>/deliberate/cases/<YYYY-MM-DD-slug>/prototype/index.html recomputable companion (the interactive prototype)
25
+ * <vault>/deliberate/cases/<YYYY-MM-DD-slug>/log.jsonl producer/critic run log
26
+ *
27
+ * There is NO separate `case.md`: a case is "just a prompt" — the raw prompt is a
28
+ * transient frontmatter input (`prompt:`) the pipeline grounds `frame` on, then
29
+ * discarded once the case summary is written; the visible record keeps only the 1–2
30
+ * sentence summary. The **title is the record's `# H1`** (no title metadata property).
31
+ * A case has a globally-unique internal `id` — a stable hash that is the only handle
32
+ * (shown in the CLI + app; there is no sequential number). The slug is the folder
33
+ * name's suffix, not a stored field. Global app-data (`~/.sonorance/config.json`):
34
+ * { current, background, vaults: {id: path} }.
35
+ */
36
+ import {
37
+ mkdirSync, writeFileSync, renameSync, readFileSync, existsSync, readdirSync, rmSync, appendFileSync,
38
+ } from 'node:fs';
39
+ import { randomBytes } from 'node:crypto';
40
+ import { join, dirname, isAbsolute, relative, sep } from 'node:path';
41
+ import { serialize, parse } from './frontmatter.mjs';
42
+ import { unwrapProse } from './markdown.mjs';
43
+ import { STAGES } from './stages.mjs';
44
+ import { CASE_ANALYSIS, CASE_ONEPAGER, CASE_SCORE, BRIEF, READOUT, MATCHUP } from './kinds.mjs';
45
+ import { STATE, STATUS } from './domain.mjs';
46
+ import {
47
+ appHome, appConfigPath, readAppConfig, vaultPath,
48
+ contextFile, competitorsFile, ecosystemFile, vaultConfigPath, vaultStatePath, sourcesFile, pluginsFile, casesDir, caseDir,
49
+ findCaseDir, slugOfCaseDir, analysisFile, onepagerFile, scoreFile, prototypeFile, prototypeDir, sidecarPath, commentsFile,
50
+ briefsDir, briefDir, briefFile, findBriefDir,
51
+ readoutsDir, readoutDir, readoutFile, readoutChartsDir, readoutChartFile, findReadoutDir,
52
+ matchupsDir, matchupDir, matchupFile, findMatchupDir, findMatchupDirBySlug,
53
+ } from './paths.mjs';
54
+ import { parseSources, serializeSources } from '../../sources.mjs';
55
+ import { createRegistry } from '../../vault-registry.mjs';
56
+
57
+ const now = () => Date.now();
58
+ // A short, collision-safe id (hex + a guaranteed letter so it parses as a string,
59
+ // like case ids) for in-record comments.
60
+ const randId = () => 'c' + randomBytes(5).toString('hex');
61
+ // A case folder slug: at most 5 words and ~20 chars, whole words, concise.
62
+ const slugify = (s) => {
63
+ const words = String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim().split(/\s+/).filter(Boolean);
64
+ let slug = '';
65
+ for (const w of words.slice(0, 5)) {
66
+ const next = slug ? `${slug}-${w}` : w;
67
+ if (next.length > 20 && slug) break; // stop before exceeding 20, keep ≥ 1 word
68
+ slug = next;
69
+ if (slug.length >= 20) break;
70
+ }
71
+ return slug.slice(0, 20).replace(/-+$/, '') || 'case';
72
+ };
73
+ const ensureDir = (d) => mkdirSync(d, { recursive: true });
74
+ // Write a file atomically (temp file + rename) so a crash mid-write can never leave a
75
+ // truncated/corrupt `.sonorance/` state file — readers always see the old or the new whole.
76
+ const atomicWrite = (p, data) => { const tmp = `${p}.tmp-${process.pid}`; writeFileSync(tmp, data); renameSync(tmp, p); };
77
+
78
+ const readJSON = (p, fallback = {}) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fallback; } };
79
+ const writeJSON = (p, obj) => { ensureDir(dirname(p)); atomicWrite(p, JSON.stringify(obj, null, 2) + '\n'); };
80
+ const readNote = (p) => { try { return parse(readFileSync(p, 'utf8')); } catch { return null; } };
81
+ const writeNote = (p, data, body = '') => { ensureDir(dirname(p)); writeFileSync(p, serialize(data, body)); };
82
+
83
+ // Enumerate the prototypes built for a case (its `prototype/` folder). One case can hold a
84
+ // prototype per PRIMARY surface: the single-surface default is the flat `prototype/index.html`
85
+ // (`surface: null`), and each additional primary surface nests as `prototype/<slug>/index.html`.
86
+ // Returns `[{ surface, path, rel }]` — flat first (if present), then nested slugs sorted — where
87
+ // `rel` is the record-relative link. Empty when nothing has been built yet.
88
+ function listPrototypeFiles(caseDirPath) {
89
+ const root = prototypeDir(caseDirPath);
90
+ const out = [];
91
+ if (existsSync(join(root, 'index.html'))) out.push({ surface: null, path: join(root, 'index.html'), rel: './prototype/index.html' });
92
+ if (existsSync(root)) for (const name of readdirSync(root).sort()) {
93
+ if (name.startsWith('.') || name === 'index.html') continue;
94
+ const f = join(root, name, 'index.html');
95
+ if (existsSync(f)) out.push({ surface: name, path: f, rel: `./prototype/${name}/index.html` });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ export class VaultStore {
101
+ constructor() {
102
+ this.caseIndex = new Map(); // caseId → { pid, dir }
103
+ this.briefIndex = new Map(); // briefId → { pid, dir }
104
+ this.readoutIndex = new Map(); // readoutId → { pid, dir }
105
+ this.matchupIndex = new Map(); // matchupId → { pid, dir }
106
+ this.registry = createRegistry(); // the shared ~/.sonorance vault registry (list/current/self-heal/prune)
107
+ ensureDir(appHome());
108
+ this.reindex();
109
+ }
110
+ close() { /* files-first: nothing to close */ }
111
+
112
+ // ---- app-data config (background flag + host-owned keys; the vault list + current pointer
113
+ // are owned by the shared registry, in the SAME ~/.sonorance/config.json) ----
114
+ #app() { return readAppConfig(); }
115
+ #mutateApp(fn) { const c = this.#app(); fn(c); writeJSON(appConfigPath(), c); return c; }
116
+
117
+ getCurrent() { return this.registry.current()?.id || null; }
118
+ setCurrent(id) { this.registry.setCurrent(id); }
119
+ getAppSettings() { return { background: this.#app().background ?? '1' }; }
120
+ setAppSetting(key, value) { this.#mutateApp(c => { c[key] = String(value); }); return this.getAppSettings(); }
121
+ // Ids of vaults whose folder still exists — the set to scan for cases/briefs (a missing vault
122
+ // has nothing on disk to index).
123
+ #projectIds() { return this.registry.list().filter(v => v.exists).map(v => v.id); }
124
+
125
+ // ---- projects (registry-backed: it owns the vault list + self-heal + current) ----
126
+ // Identity (id/name/repo/created_at) lives in the ONE per-vault file,
127
+ // `.sonorance/config.json`; grounding sources live in the hand-editable `.sonorance/sources.md`
128
+ // (see below). Read-modify-write preserves the workbench/state keys the app owns in config.json.
129
+ #writeIdentity(vault, fields) {
130
+ const cfg = readJSON(vaultConfigPath(vault));
131
+ Object.assign(cfg, fields);
132
+ writeJSON(vaultConfigPath(vault), cfg);
133
+ return cfg;
134
+ }
135
+ // Enable the Deliberate plugin for this vault (in `.sonorance/plugins.json`) so the generic
136
+ // `sonorance serve` discovers and composes the Deliberate flavor — not just `deliberate serve`.
137
+ // Idempotent; id-based (the host maps the id → its bundled `plugins/deliberate` contribution).
138
+ #registerPlugin(vault) {
139
+ const p = pluginsFile(vault);
140
+ const j = readJSON(p);
141
+ const plugins = Array.isArray(j.plugins) ? j.plugins : [];
142
+ const existing = plugins.find(x => x && x.id === 'deliberate');
143
+ if (existing) { delete existing.module; if (existing.enabled == null) existing.enabled = true; }
144
+ else plugins.push({ id: 'deliberate', enabled: true });
145
+ writeJSON(p, { ...j, plugins });
146
+ }
147
+ // Register an existing folder as a project vault (`deliberate open <folder>` / the app's
148
+ // "Open folder…"). Enables the Deliberate plugin, then registers + switches to it. Context
149
+ // scaffolding (product.md / competitors.md) is owned by the skill's `deliberate init`.
150
+ openVaultFolder(vault, { name, makeCurrent = true } = {}) {
151
+ ensureDir(casesDir(vault));
152
+ ensureDir(dirname(vaultConfigPath(vault)));
153
+ const cfg = readJSON(vaultConfigPath(vault));
154
+ let id = cfg.id;
155
+ if (id) { name = cfg.name || id; }
156
+ else { id = slugify(name || vault.replace(/.*[\\/]/, '')); name = name || id; this.#writeIdentity(vault, { id, name, repo: cfg.repo ?? null, created_at: cfg.created_at ?? now() }); }
157
+ this.#registerPlugin(vault);
158
+ const sourcePath = sourcesFile(vault);
159
+ if (!existsSync(sourcePath)) writeFileSync(sourcePath, serializeSources([]));
160
+ this.registry.register(vault, { name, makeCurrent });
161
+ this.reindex();
162
+ return this.getProject(id);
163
+ }
164
+ // Resilient: any registered vault resolves (the registry self-heals a live folder that lost
165
+ // its identity) — a live project is never silently dropped. Returns undefined only if the id
166
+ // isn't registered at all. `exists:false` flags a vault whose folder is gone (for the switcher).
167
+ getProject(id) {
168
+ const v = this.registry.get(id);
169
+ if (!v) return undefined;
170
+ const cfg = readJSON(vaultConfigPath(v.dir));
171
+ return { id: v.id, name: cfg.name || v.name, repo: cfg.repo || null, created_at: cfg.created_at || null, dir: v.dir, exists: v.exists };
172
+ }
173
+ listProjects() {
174
+ return this.registry.list().map(v => this.getProject(v.id)).filter(Boolean)
175
+ .sort((a, b) => (a.created_at || 0) - (b.created_at || 0));
176
+ }
177
+ renameProject(id, name) { this.#patchProject(id, { name }); }
178
+ setRepo(id, repo) { this.#patchProject(id, { repo: repo || null }); }
179
+ #patchProject(id, fields) {
180
+ const vault = this.registry.get(id)?.dir; if (!vault) return;
181
+ this.#writeIdentity(vault, fields);
182
+ }
183
+ // Forget a vault from the switcher (never deletes its folder) — the app's Remove action.
184
+ removeProject(id) { this.registry.remove(id); }
185
+
186
+ // ---- sources (hand-editable Markdown in .sonorance/sources.md) ----
187
+ // A grounding source is a location + optional note grouped under a user-editable Markdown
188
+ // section. The Markdown file is the source of truth; no JSON mirror exists.
189
+ #sourcesPath(id) { return sourcesFile(vaultPath(id)); }
190
+ #readSources(id) { try { return parseSources(readFileSync(this.#sourcesPath(id), 'utf8')); } catch { return []; } }
191
+ #writeSources(id, list) { const p = this.#sourcesPath(id); ensureDir(dirname(p)); writeFileSync(p, serializeSources(list)); }
192
+ addSource(pid, location, description = null, section = 'other') {
193
+ const list = this.#readSources(pid);
194
+ if (!list.some(s => s.location === location)) list.push({ location, description: description || '', section });
195
+ this.#writeSources(pid, list);
196
+ }
197
+ rmSource(pid, location) { this.#writeSources(pid, this.#readSources(pid).filter(s => s.location !== location)); }
198
+ listSources(pid) {
199
+ return this.#readSources(pid).map(s => ({
200
+ project_id: pid, location: s.location, description: s.description || null,
201
+ section: s.section || 'other', sectionTitle: s.sectionTitle || 'Other',
202
+ }));
203
+ }
204
+
205
+ // ---- project context (host-written MARKDOWN in deliberate/context/product.md) ----
206
+ // Context is a human/agent-readable markdown doc (sections + lists), not metadata.
207
+ // The host writes it during `/deliberate init`; the engine injects it read-only.
208
+ readContext(pid) { try { return readFileSync(contextFile(vaultPath(pid)), 'utf8'); } catch { return ''; } }
209
+ writeContext(pid, content) { const p = contextFile(vaultPath(pid)); ensureDir(dirname(p)); writeFileSync(p, content.endsWith('\n') ? content : content + '\n'); }
210
+ readCompetitors(pid) { try { return readFileSync(competitorsFile(vaultPath(pid)), 'utf8'); } catch { return ''; } }
211
+ writeCompetitors(pid, content) { const p = competitorsFile(vaultPath(pid)); ensureDir(dirname(p)); writeFileSync(p, content.endsWith('\n') ? content : content + '\n'); }
212
+ readEcosystem(pid) { try { return readFileSync(ecosystemFile(vaultPath(pid)), 'utf8'); } catch { return ''; } }
213
+ writeEcosystem(pid, content) { const p = ecosystemFile(vaultPath(pid)); ensureDir(dirname(p)); writeFileSync(p, content.endsWith('\n') ? content : content + '\n'); }
214
+
215
+ // ---- config: per-project settings in .sonorance/config.json (gate default, etc.) ----
216
+ #vaultCfg(id) { return readJSON(vaultConfigPath(vaultPath(id))); }
217
+ #writeVaultCfg(id, cfg) { writeJSON(vaultConfigPath(vaultPath(id)), cfg); }
218
+ setConfig(pid, key, value) { const cfg = this.#vaultCfg(pid); cfg[key] = value; this.#writeVaultCfg(pid, cfg); }
219
+ getConfig(pid) {
220
+ const out = {};
221
+ const cfg = this.#vaultCfg(pid);
222
+ for (const [k, v] of Object.entries(cfg)) if (v != null) out[k] = v;
223
+ return out;
224
+ }
225
+
226
+ // ---- editor state: open tabs / active tab / Explorer, in .sonorance/local/state.json ----
227
+ // Session state, NOT config — disposable and regenerable, never needed to open a vault; kept
228
+ // out of config.json so identity/sources stay clean. Mirrors sonorance-app's engine-default.
229
+ #vaultState(id) { return readJSON(vaultStatePath(vaultPath(id))); }
230
+ #writeVaultState(id, st) { writeJSON(vaultStatePath(vaultPath(id)), st); }
231
+ getState(pid) { return this.#vaultState(pid); }
232
+ setState(pid, key, value) { const st = this.#vaultState(pid); st[key] = value; this.#writeVaultState(pid, st); }
233
+ // Merge many keys in ONE read-modify-write (atomic) — the app's buffered-flush target so a
234
+ // multi-key patch (e.g. Explorer collapse state) never does a partial series of rewrites.
235
+ setStateMany(pid, patch) { const st = this.#vaultState(pid); for (const [k, v] of Object.entries(patch || {})) st[k] = v; this.#writeVaultState(pid, st); }
236
+
237
+ // ---- active case (per-project pointer in .config/config.json) ----
238
+ // The case later commands act on when no explicit <N> is given. Set on create,
239
+ // by `use <N>`, or by the host from conversation context. Stores the internal id.
240
+ getActiveCase(pid) { const v = this.#vaultCfg(pid).active_case; return v == null ? null : String(v); }
241
+ setActiveCase(pid, id) {
242
+ const cfg = this.#vaultCfg(pid);
243
+ if (id == null) delete cfg.active_case; else cfg.active_case = String(id);
244
+ this.#writeVaultCfg(pid, cfg);
245
+ }
246
+
247
+ // ---- cases ----
248
+ // The case's globally-unique internal id: a random hex hash (NOT a sequential
249
+ // integer), so cases created independently by multiple collaborators never collide
250
+ // when their folders are merged. Always contains a letter (so it parses as a string,
251
+ // never a YAML number). This id is the ONLY case handle — there is no sequential number.
252
+ #nextCaseId() {
253
+ const known = new Set([...this.caseIndex.keys(), ...this.briefIndex.keys(), ...this.readoutIndex.keys(), ...this.matchupIndex.keys()]);
254
+ let id;
255
+ do { id = randomBytes(6).toString('hex'); } while (known.has(id) || !/[a-f]/.test(id));
256
+ return id;
257
+ }
258
+ createCase(pid, title, description) {
259
+ const id = this.#nextCaseId();
260
+ const slug = slugify(title || description);
261
+ const vault = vaultPath(pid);
262
+ const t = now();
263
+ // Folder is `YYYY-MM-DD-slug`; on a same-day/same-slug collision, disambiguate.
264
+ let dir = caseDir(vault, slug, t), n = 2;
265
+ while (existsSync(dir)) dir = caseDir(vault, `${slug}-${n++}`, t);
266
+ ensureDir(dir);
267
+ // The case IS its analysis.md from creation: minimal lifecycle frontmatter + the
268
+ // raw prompt (transient, discarded once summarized) + the title as the body `# H1`.
269
+ const data = { id, state: STATE.NEW, created: t, updated: t };
270
+ if (description) data.prompt = description;
271
+ this.#writeAnalysis(dir, { data, title: title || 'Untitled', summary: '', sections: {} });
272
+ this.caseIndex.set(id, { pid, dir });
273
+ return this.getCase(id);
274
+ }
275
+ #caseLoc(id) {
276
+ id = String(id);
277
+ let loc = this.caseIndex.get(id);
278
+ if (loc && existsSync(loc.dir)) return loc;
279
+ // Fall back to a scan (e.g. created by another process) and refresh the index.
280
+ for (const pid of this.#projectIds()) {
281
+ const dir = findCaseDir(vaultPath(pid), id);
282
+ if (dir) { loc = { pid, dir }; this.caseIndex.set(id, loc); return loc; }
283
+ }
284
+ return null;
285
+ }
286
+ #caseRow(loc) {
287
+ const { data, title, summary, exists } = this.#readAnalysis(loc.dir);
288
+ if (!exists || data.id == null) return undefined;
289
+ const id = String(data.id);
290
+ return {
291
+ id, project_id: loc.pid, title: title || 'Untitled',
292
+ description: data.prompt || '', summary: summary || '', slug: slugOfCaseDir(loc.dir.replace(/.*[\\/]/, '')),
293
+ score: data.score ?? null, state: data.state, run_token: data.run_token ?? null,
294
+ created_at: data.created, updated_at: data.updated,
295
+ };
296
+ }
297
+ getCase(id) { const loc = this.#caseLoc(id); return loc ? this.#caseRow(loc) : undefined; }
298
+ // Update a case's lifecycle frontmatter (state / run_token / prompt / score) in place,
299
+ // preserving the record body byte-for-byte (no structured reflow). Derived fields
300
+ // (title, slug) are ignored; the go/no-go score is normally written via `writeScore`
301
+ // (score.md), but is accepted here too so callers can stamp it without a body.
302
+ setCase(id, fields) {
303
+ id = String(id);
304
+ const KEEP = new Set(['state', 'run_token', 'prompt', 'score']);
305
+ const patch = {};
306
+ for (const [k, v] of Object.entries(fields)) if (KEEP.has(k)) patch[k] = v;
307
+ if (Object.keys(patch).length) {
308
+ const loc = this.#caseLoc(id); if (!loc) return this.getCase(id);
309
+ const n = readNote(analysisFile(loc.dir)); if (!n) return this.getCase(id);
310
+ const data = { ...n.data };
311
+ for (const [k, v] of Object.entries(patch)) { if (v == null) delete data[k]; else data[k] = v; }
312
+ data.updated = now();
313
+ writeNote(analysisFile(loc.dir), data, n.body);
314
+ }
315
+ return this.getCase(id);
316
+ }
317
+ deleteCase(id) {
318
+ id = String(id);
319
+ const loc = this.#caseLoc(id);
320
+ if (loc) { this.#pruneCommentsUnder(loc.pid, loc.dir); try { rmSync(loc.dir, { recursive: true, force: true }); } catch {} this.caseIndex.delete(id); }
321
+ }
322
+ listCases(pid, { state, min } = {}) {
323
+ const root = casesDir(vaultPath(pid));
324
+ if (!existsSync(root)) return [];
325
+ const rows = [];
326
+ for (const name of readdirSync(root)) {
327
+ const dir = join(root, name);
328
+ const row = this.#caseRow({ pid, dir }); if (!row) continue;
329
+ if (this.caseIndex.get(row.id)?.dir !== dir) this.caseIndex.set(row.id, { pid, dir });
330
+ row.last_stage_at = this.#lastStageAt(dir);
331
+ if (state && row.state !== state) continue;
332
+ if (min != null && !(row.score >= min)) continue;
333
+ rows.push(row);
334
+ }
335
+ return rows.sort((a, b) => (b.created_at - a.created_at) || String(b.id).localeCompare(String(a.id)));
336
+ }
337
+ #lastStageAt(dir) {
338
+ const { data } = this.#readAnalysis(dir);
339
+ let m = null;
340
+ for (const stage of STAGES) { const e = data[`${stage}_ended`]; if (e != null) m = Math.max(m ?? 0, e); }
341
+ return m;
342
+ }
343
+
344
+ // ---- briefs (project-scoped landscape reports; NOT cases) ----
345
+ //
346
+ // A brief is one Markdown document — `deliberate/briefs/<YYYY-MM-DD>/brief.md` —
347
+ // capturing the competitive + market changes since the last brief. Its stable hash
348
+ // `id` lives in the frontmatter (like cases; the id is the only handle); `period_start`
349
+ // / `period_end` bound the window it covers. Briefs are read-only in the app;
350
+ // the host authors them in-session via the Briefer role.
351
+ #nextBriefId() {
352
+ const known = new Set([...this.briefIndex.keys(), ...this.readoutIndex.keys(), ...this.matchupIndex.keys(), ...this.caseIndex.keys()]);
353
+ let id;
354
+ do { id = randomBytes(6).toString('hex'); } while (known.has(id) || !/[a-f]/.test(id));
355
+ return id;
356
+ }
357
+ #briefLoc(id) {
358
+ id = String(id);
359
+ let loc = this.briefIndex.get(id);
360
+ if (loc && existsSync(loc.dir)) return loc;
361
+ for (const pid of this.#projectIds()) {
362
+ const dir = findBriefDir(vaultPath(pid), id);
363
+ if (dir) { loc = { pid, dir }; this.briefIndex.set(id, loc); return loc; }
364
+ }
365
+ return null;
366
+ }
367
+ #briefRow(loc) {
368
+ const n = readNote(briefFile(loc.dir)); if (!n) return undefined;
369
+ const d = n.data;
370
+ return {
371
+ id: String(d.id), project_id: loc.pid,
372
+ period_start: d.period_start ?? null, period_end: d.period_end ?? null,
373
+ model: d.model ?? null, created_at: d.created_at, updated_at: d.updated_at,
374
+ };
375
+ }
376
+ getBrief(id) { const loc = this.#briefLoc(id); return loc ? this.#briefRow(loc) : undefined; }
377
+ // The end of the window the newest brief covered — the floor for the NEXT brief's
378
+ // "since the last brief" window. Null when this project has no brief yet.
379
+ lastBriefEnd(pid) {
380
+ const ends = this.listBriefs(pid).map(b => b.period_end).filter(v => v != null);
381
+ return ends.length ? Math.max(...ends) : null;
382
+ }
383
+ // Create a brief folder dated `at` (default now) and write its frontmatter + body.
384
+ createBrief(pid, { period_start, period_end, model = null, body = '' }, at = now()) {
385
+ const id = this.#nextBriefId();
386
+ const vault = vaultPath(pid);
387
+ let dir = briefDir(vault, at), n = 2;
388
+ while (existsSync(dir)) dir = briefDir(vault, at) + '-' + n++; // same-day disambiguation
389
+ ensureDir(dir);
390
+ writeNote(briefFile(dir), { type: BRIEF, id, period_start, period_end, model: model || null, created_at: at, updated_at: at }, body);
391
+ this.briefIndex.set(id, { pid, dir });
392
+ return this.getBrief(id);
393
+ }
394
+ listBriefs(pid) {
395
+ const root = briefsDir(vaultPath(pid));
396
+ if (!existsSync(root)) return [];
397
+ const rows = [];
398
+ for (const name of readdirSync(root)) {
399
+ if (name.startsWith('.')) continue;
400
+ const dir = join(root, name);
401
+ const row = this.#briefRow({ pid, dir }); if (!row) continue;
402
+ if (this.briefIndex.get(row.id)?.dir !== dir) this.briefIndex.set(row.id, { pid, dir });
403
+ rows.push(row);
404
+ }
405
+ return rows.sort((a, b) => (b.period_end || b.created_at || 0) - (a.period_end || a.created_at || 0) || String(b.id).localeCompare(String(a.id)));
406
+ }
407
+ deleteBrief(id) {
408
+ id = String(id);
409
+ const loc = this.#briefLoc(id);
410
+ if (loc) { this.#pruneCommentsUnder(loc.pid, loc.dir); try { rmSync(loc.dir, { recursive: true, force: true }); } catch {} this.briefIndex.delete(id); }
411
+ }
412
+ briefFilePath(id) { const loc = this.#briefLoc(id); return loc ? briefFile(loc.dir) : null; }
413
+ // Read a brief's raw record (the whole brief.md), for the app reading view.
414
+ readBriefRecord(id) {
415
+ const loc = this.#briefLoc(id); if (!loc) return null;
416
+ const meta = readNote(briefFile(loc.dir));
417
+ let text = ''; try { text = readFileSync(briefFile(loc.dir), 'utf8'); } catch {}
418
+ const d = meta?.data || {};
419
+ return {
420
+ id: String(d.id ?? id), title: 'Brief',
421
+ period_start: d.period_start ?? null, period_end: d.period_end ?? null,
422
+ file: briefFile(loc.dir), exists: !!meta, text,
423
+ };
424
+ }
425
+ writeBriefRecord(id, text) {
426
+ const loc = this.#briefLoc(id); if (!loc) return false;
427
+ const file = briefFile(loc.dir); ensureDir(dirname(file));
428
+ writeFileSync(file, text.endsWith('\n') ? text : text + '\n');
429
+ return true;
430
+ }
431
+
432
+ // ---- readouts (project-scoped product performance reports) ----
433
+ #nextReadoutId() {
434
+ const known = new Set([...this.readoutIndex.keys(), ...this.matchupIndex.keys(), ...this.briefIndex.keys(), ...this.caseIndex.keys()]);
435
+ let id;
436
+ do { id = randomBytes(6).toString('hex'); } while (known.has(id) || !/[a-f]/.test(id));
437
+ return id;
438
+ }
439
+ #readoutLoc(id) {
440
+ id = String(id);
441
+ let loc = this.readoutIndex.get(id);
442
+ if (loc && existsSync(loc.dir)) return loc;
443
+ for (const pid of this.#projectIds()) {
444
+ const dir = findReadoutDir(vaultPath(pid), id);
445
+ if (dir) { loc = { pid, dir }; this.readoutIndex.set(id, loc); return loc; }
446
+ }
447
+ return null;
448
+ }
449
+ #readoutRow(loc) {
450
+ const n = readNote(readoutFile(loc.dir)); if (!n) return undefined;
451
+ const d = n.data;
452
+ return {
453
+ id: String(d.id), project_id: loc.pid,
454
+ period_start: d.period_start ?? null, period_end: d.period_end ?? null,
455
+ model: d.model ?? null, created_at: d.created_at, updated_at: d.updated_at,
456
+ };
457
+ }
458
+ getReadout(id) { const loc = this.#readoutLoc(id); return loc ? this.#readoutRow(loc) : undefined; }
459
+ lastReadoutEnd(pid) {
460
+ const ends = this.listReadouts(pid).map(r => r.period_end).filter(v => v != null);
461
+ return ends.length ? Math.max(...ends) : null;
462
+ }
463
+ createReadout(pid, { period_start, period_end, model = null, body = '', charts = [] }, at = now()) {
464
+ const id = this.#nextReadoutId();
465
+ const vault = vaultPath(pid);
466
+ let dir = readoutDir(vault, at), n = 2;
467
+ while (existsSync(dir)) dir = readoutDir(vault, at) + '-' + n++;
468
+ const temp = join(readoutsDir(vault), `.tmp-${id}`);
469
+ ensureDir(readoutsDir(vault));
470
+ try {
471
+ ensureDir(temp);
472
+ writeNote(readoutFile(temp), { type: READOUT, id, period_start, period_end, model: model || null, created_at: at, updated_at: at }, body);
473
+ if (charts.length) {
474
+ ensureDir(readoutChartsDir(temp));
475
+ for (const chart of charts) {
476
+ if (!/^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\.svg$/.test(chart?.name || '')) throw new Error(`Invalid Readout chart filename: ${chart?.name || ''}`);
477
+ if (!Buffer.isBuffer(chart.content) || !chart.content.length) throw new Error(`Invalid Readout chart content: ${chart.name}`);
478
+ writeFileSync(readoutChartFile(temp, chart.name), chart.content);
479
+ }
480
+ }
481
+ renameSync(temp, dir);
482
+ } catch (error) {
483
+ rmSync(temp, { recursive: true, force: true });
484
+ throw error;
485
+ }
486
+ this.readoutIndex.set(id, { pid, dir });
487
+ return this.getReadout(id);
488
+ }
489
+ listReadouts(pid) {
490
+ const root = readoutsDir(vaultPath(pid));
491
+ if (!existsSync(root)) return [];
492
+ const rows = [];
493
+ for (const name of readdirSync(root)) {
494
+ if (name.startsWith('.')) continue;
495
+ const dir = join(root, name);
496
+ const row = this.#readoutRow({ pid, dir }); if (!row) continue;
497
+ if (this.readoutIndex.get(row.id)?.dir !== dir) this.readoutIndex.set(row.id, { pid, dir });
498
+ rows.push(row);
499
+ }
500
+ return rows.sort((a, b) => (b.period_end || b.created_at || 0) - (a.period_end || a.created_at || 0) || String(b.id).localeCompare(String(a.id)));
501
+ }
502
+ deleteReadout(id) {
503
+ id = String(id);
504
+ const loc = this.#readoutLoc(id);
505
+ if (loc) { this.#pruneCommentsUnder(loc.pid, loc.dir); try { rmSync(loc.dir, { recursive: true, force: true }); } catch {} this.readoutIndex.delete(id); }
506
+ }
507
+ readoutFilePath(id) { const loc = this.#readoutLoc(id); return loc ? readoutFile(loc.dir) : null; }
508
+ readReadoutRecord(id) {
509
+ const loc = this.#readoutLoc(id); if (!loc) return null;
510
+ const meta = readNote(readoutFile(loc.dir));
511
+ let text = ''; try { text = readFileSync(readoutFile(loc.dir), 'utf8'); } catch {}
512
+ const d = meta?.data || {};
513
+ return {
514
+ id: String(d.id ?? id), title: 'Product readout',
515
+ period_start: d.period_start ?? null, period_end: d.period_end ?? null,
516
+ file: readoutFile(loc.dir), exists: !!meta, text,
517
+ };
518
+ }
519
+ writeReadoutRecord(id, text) {
520
+ const loc = this.#readoutLoc(id); if (!loc) return false;
521
+ const file = readoutFile(loc.dir); ensureDir(dirname(file));
522
+ writeFileSync(file, text.endsWith('\n') ? text : text + '\n');
523
+ return true;
524
+ }
525
+
526
+ // ---- matchups (project-scoped single-competitor head-to-heads; NOT cases/briefs) ----
527
+ //
528
+ // A matchup is one Markdown document — `deliberate/matchups/<competitor-slug>/matchup.md` —
529
+ // a full point-in-time read of ONE named rival against us. Unlike briefs (date-keyed,
530
+ // append-only time series), a matchup is COMPETITOR-KEYED and REFRESHED IN PLACE: there is
531
+ // exactly one canonical matchup per rival, re-run to update. Its stable hash `id` + the
532
+ // `as_of` date (ms) live in the frontmatter (the id is the cross-collaborator handle; the
533
+ // folder slug is the human handle). Matchups are read-only in the app; the host authors
534
+ // them in-session via the Scout role.
535
+ #nextMatchupId() {
536
+ const known = new Set([...this.matchupIndex.keys(), ...this.readoutIndex.keys(), ...this.briefIndex.keys(), ...this.caseIndex.keys()]);
537
+ let id;
538
+ do { id = randomBytes(6).toString('hex'); } while (known.has(id) || !/[a-f]/.test(id));
539
+ return id;
540
+ }
541
+ #matchupLoc(id) {
542
+ id = String(id);
543
+ let loc = this.matchupIndex.get(id);
544
+ if (loc && existsSync(loc.dir)) return loc;
545
+ for (const pid of this.#projectIds()) {
546
+ const dir = findMatchupDir(vaultPath(pid), id);
547
+ if (dir) { loc = { pid, dir }; this.matchupIndex.set(id, loc); return loc; }
548
+ }
549
+ return null;
550
+ }
551
+ #matchupRow(loc) {
552
+ const n = readNote(matchupFile(loc.dir)); if (!n) return undefined;
553
+ const d = n.data;
554
+ return {
555
+ id: String(d.id), project_id: loc.pid,
556
+ competitor: d.competitor ?? null, slug: d.slug ?? null, as_of: d.as_of ?? null,
557
+ model: d.model ?? null, created_at: d.created_at, updated_at: d.updated_at,
558
+ };
559
+ }
560
+ getMatchup(id) { const loc = this.#matchupLoc(id); return loc ? this.#matchupRow(loc) : undefined; }
561
+ // The existing matchup for a competitor slug (the key that makes a matchup refresh-in-place),
562
+ // or undefined. Used by the Scout prompt to inject the prior read for an in-place update.
563
+ getMatchupBySlug(pid, slug) {
564
+ const dir = findMatchupDirBySlug(vaultPath(pid), slug);
565
+ return dir ? this.#matchupRow({ pid, dir }) : undefined;
566
+ }
567
+ // The canonical matchup for a competitor NAME — slugifies the name the same way createMatchup
568
+ // does, so the engine can look up the prior read (for a refresh) without owning slug logic.
569
+ matchupForCompetitor(pid, competitor) { return this.getMatchupBySlug(pid, slugify(competitor)); }
570
+ // Create OR refresh a matchup for a competitor (upsert by slug). A fresh rival gets a new
571
+ // folder + hash id + created_at; an existing rival's canonical doc is rewritten in place,
572
+ // keeping its id + created_at and restamping as_of + updated_at (git carries the history).
573
+ createMatchup(pid, { competitor, as_of, model = null, body = '' }, at = now()) {
574
+ const vault = vaultPath(pid);
575
+ const slug = slugify(competitor);
576
+ const existing = findMatchupDirBySlug(vault, slug);
577
+ let dir, id, created_at;
578
+ if (existing) {
579
+ dir = existing;
580
+ const prev = readNote(matchupFile(dir))?.data || {};
581
+ id = String(prev.id ?? this.#nextMatchupId());
582
+ created_at = prev.created_at ?? at;
583
+ } else {
584
+ dir = matchupDir(vault, slug); ensureDir(dir);
585
+ id = this.#nextMatchupId(); created_at = at;
586
+ }
587
+ writeNote(matchupFile(dir), { type: MATCHUP, id, competitor, slug, as_of, model: model || null, created_at, updated_at: at }, body);
588
+ this.matchupIndex.set(id, { pid, dir });
589
+ return this.getMatchup(id);
590
+ }
591
+ listMatchups(pid) {
592
+ const root = matchupsDir(vaultPath(pid));
593
+ if (!existsSync(root)) return [];
594
+ const rows = [];
595
+ for (const name of readdirSync(root)) {
596
+ if (name.startsWith('.')) continue;
597
+ const dir = join(root, name);
598
+ const row = this.#matchupRow({ pid, dir }); if (!row) continue;
599
+ if (this.matchupIndex.get(row.id)?.dir !== dir) this.matchupIndex.set(row.id, { pid, dir });
600
+ rows.push(row);
601
+ }
602
+ return rows.sort((a, b) => (b.updated_at || b.as_of || 0) - (a.updated_at || a.as_of || 0) || String(a.slug).localeCompare(String(b.slug)));
603
+ }
604
+ deleteMatchup(id) {
605
+ id = String(id);
606
+ const loc = this.#matchupLoc(id);
607
+ if (loc) { this.#pruneCommentsUnder(loc.pid, loc.dir); try { rmSync(loc.dir, { recursive: true, force: true }); } catch {} this.matchupIndex.delete(id); }
608
+ }
609
+ matchupFilePath(id) { const loc = this.#matchupLoc(id); return loc ? matchupFile(loc.dir) : null; }
610
+ // Read a matchup's raw record (the whole matchup.md), for the app reading view.
611
+ readMatchupRecord(id) {
612
+ const loc = this.#matchupLoc(id); if (!loc) return null;
613
+ const meta = readNote(matchupFile(loc.dir));
614
+ let text = ''; try { text = readFileSync(matchupFile(loc.dir), 'utf8'); } catch {}
615
+ const d = meta?.data || {};
616
+ return {
617
+ id: String(d.id ?? id), title: d.competitor ? `Matchup — ${d.competitor}` : 'Matchup',
618
+ competitor: d.competitor ?? null, slug: d.slug ?? null, as_of: d.as_of ?? null,
619
+ file: matchupFile(loc.dir), exists: !!meta, text,
620
+ };
621
+ }
622
+ writeMatchupRecord(id, text) {
623
+ const loc = this.#matchupLoc(id); if (!loc) return false;
624
+ const file = matchupFile(loc.dir); ensureDir(dirname(file));
625
+ writeFileSync(file, text.endsWith('\n') ? text : text + '\n');
626
+ return true;
627
+ }
628
+
629
+ // ---- the combined decision record: one `analysis.md` per case ----
630
+ //
631
+ // Every stage's output lands in a single human-readable document (a section per
632
+ // stage, in funnel order). Lightweight per-stage machine state (status / score /
633
+ // model / harness / session / timestamps / error) lives in that file's frontmatter as
634
+ // flat `<stage>_<field>` keys; the produced text lives in the body under a plain
635
+ // `## <Stage>` heading (no HTML-comment markers — sections are delimited by their
636
+ // headings alone). Absence of a stage's frontmatter status = pending.
637
+ #stageLabel(stage) { return stage.charAt(0).toUpperCase() + stage.slice(1); }
638
+ #readAnalysis(dir) {
639
+ const n = readNote(analysisFile(dir));
640
+ return { data: n?.data || {}, ...this.#parseBody(n?.body || ''), exists: !!n };
641
+ }
642
+ // Parse a record body into its title (`# H1`), the case-summary lede (any prose
643
+ // between the title and the first `## ` heading), and the `## <heading>` sections
644
+ // (Key highlights + one per stage). Sections are delimited by their top-level
645
+ // `## <heading>` alone (no markers); a stage's artifact has its own headings demoted
646
+ // to `###`+ (see pipeline.embedArtifact), so a bare `## ` only ever starts a new
647
+ // section. Fenced code blocks are skipped so a literal `## ` inside a code sample
648
+ // can't be mistaken for a delimiter.
649
+ #parseBody(body) {
650
+ const sections = {};
651
+ let title = '', lede = [];
652
+ const labelToStage = {};
653
+ for (const s of STAGES) labelToStage[this.#stageLabel(s)] = s;
654
+ let curKey = null, buf = [], fence = false;
655
+ const flush = () => {
656
+ if (curKey && curKey !== 'prototype' && curKey !== 'onepager') {
657
+ // Drop any legacy `<!--full:stage-->` / `<!--highlights-->` marker lines so an
658
+ // old record that still carries them self-heals to a marker-free file on rewrite.
659
+ const text = buf.join('\n').replace(/^<!--\/?(?:full:[\w-]+|highlights)-->[^\n]*\n?/gm, '').trim();
660
+ if (curKey === 'highlights') sections.highlights = text;
661
+ else sections[curKey] = { full: text };
662
+ }
663
+ buf = [];
664
+ };
665
+ for (const line of body.split('\n')) {
666
+ if (/^\s*```/.test(line)) fence = !fence;
667
+ const h1 = !fence && !title && /^#\s+(.+?)\s*$/.exec(line);
668
+ if (h1) { title = h1[1].trim(); continue; }
669
+ const m = !fence && /^##\s+(.+?)\s*$/.exec(line);
670
+ if (m) { flush(); const label = m[1].trim(); curKey = label === 'Key highlights' ? 'highlights' : label === 'One-pager' ? 'onepager' : label === 'Prototype' ? 'prototype' : (labelToStage[label] ?? null); }
671
+ else if (curKey) buf.push(line);
672
+ else if (title) lede.push(line); // prose between the H1 and the first `##` = the case summary
673
+ }
674
+ flush();
675
+ return { title, summary: lede.join('\n').trim(), sections };
676
+ }
677
+ // Rebuild the whole analysis.md from the title + summary + parsed sections +
678
+ // frontmatter state. One clean hierarchy: `# Title` → the 1–2 sentence case summary
679
+ // lede → optional `## Key highlights` → a `## <Stage>` per stage (its artifact's
680
+ // headings demoted to `###`+, its own title dropped). The Prototype section is a
681
+ // single sentence linking to the sidecar. Lifecycle + per-stage model/score/timestamps
682
+ // live in the frontmatter; the title is the `# H1` (no title metadata property).
683
+ #writeAnalysis(dir, { data, title, summary, sections }) {
684
+ title = title || 'Untitled';
685
+ const fm = { type: CASE_ANALYSIS }; // document kind — the app keys presentation off this
686
+ for (const k of ['id', 'state', 'run_token', 'score', 'created']) if (data[k] != null) fm[k] = data[k];
687
+ fm.updated = now();
688
+ // The raw prompt is a transient input kept only until the case is summarized: once a
689
+ // 1–2 sentence summary lede exists (the host writes it under the H1), drop it — a case
690
+ // is "just a prompt", condensed to that lede, and later stages ground on it + the frame.
691
+ if (data.prompt != null && !(summary && summary.trim())) fm.prompt = data.prompt;
692
+ if (data.prototype != null) fm.prototype = data.prototype;
693
+ for (const s of STAGES) for (const k of ['status', 'score', 'model', 'harness', 'session', 'started', 'ended', 'error']) {
694
+ const key = `${s}_${k}`; if (data[key] != null) fm[key] = data[key];
695
+ }
696
+ const parts = [`# ${title}`, ''];
697
+ // The summary lede and Key highlights are host-written as DIRECT edits to analysis.md, so they
698
+ // never pass through `save`'s unwrapProse — reflow them here (on every re-render) so a
699
+ // hard-wrapped paragraph isn't saved as artificially chopped-up lines. Stage `sec.full` was
700
+ // already unwrapped on save; re-unwrapping is idempotent.
701
+ if (summary && summary.trim()) parts.push(unwrapProse(summary.trim()), '');
702
+ if (sections.highlights) parts.push('## Key highlights', '', unwrapProse(sections.highlights), '');
703
+ // The score is the decorrelated Evaluator's verdict, its own recomputable companion file
704
+ // (score.md) beside this record — link to it here (like the one-pager) once it exists, with
705
+ // the go/no-go number pulled from the top-level frontmatter. A synthesized section that
706
+ // #parseBody discards and this renderer regenerates from the file's presence.
707
+ if (existsSync(scoreFile(dir)))
708
+ parts.push('## Score', '', `${data.score != null ? `**${data.score}/10.** ` : ''}The decorrelated Evaluator's go/no-go verdict for this case — [read it](./score.md).`, '');
709
+ // The one-pager is an INTERNAL reverse PR-FAQ companion file (one-pager.md) beside this record —
710
+ // the case in the customer's own voice, for the team deciding; link to it here (like the
711
+ // prototype) once it exists — a synthesized section that #parseBody discards and this renderer
712
+ // regenerates from the file's presence.
713
+ if (existsSync(onepagerFile(dir)))
714
+ parts.push('## One-pager', '', 'An internal reverse PR-FAQ for this case — the customer\'s own voice as a narrative + FAQ — [read it](./one-pager.md).', '');
715
+ // The prototype is a recomputable companion built on request, like the score — not a funnel
716
+ // stage. A case can hold one prototype per PRIMARY surface (init marks them); link each built
717
+ // surface here from the folder's presence: a single one is a sentence (surface named when it
718
+ // isn't the default), several become a bulleted list. #parseBody discards this whole section
719
+ // and this renderer regenerates it.
720
+ {
721
+ const protos = listPrototypeFiles(dir);
722
+ if (protos.length === 1) {
723
+ const p = protos[0];
724
+ parts.push('## Prototype', '', `An interactive prototype of the primary journey${p.surface ? ` on the **${p.surface}** surface` : ''} — [open it](${p.rel}).`, '');
725
+ } else if (protos.length) {
726
+ parts.push('## Prototype', '', 'Interactive prototypes of the primary journey, one per primary surface:', '',
727
+ ...protos.map(p => `- **${p.surface || 'primary'}** — [open it](${p.rel}).`), '');
728
+ }
729
+ }
730
+ for (const s of STAGES) {
731
+ const sec = sections[s]; if (!sec || sec.full == null) continue;
732
+ parts.push(`## ${this.#stageLabel(s)}`, '', sec.full, '');
733
+ }
734
+ writeNote(analysisFile(dir), fm, parts.join('\n').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n');
735
+ }
736
+ #stageRow(caseId, name, data, sections) {
737
+ return { case_id: +caseId, name, status: data[`${name}_status`] ?? STATUS.PENDING, summary: sections?.[name]?.summary ?? null, score: data[`${name}_score`] ?? null, harness: data[`${name}_harness`] ?? null, session: data[`${name}_session`] ?? null, started_at: data[`${name}_started`] ?? null, ended_at: data[`${name}_ended`] ?? null, error: data[`${name}_error`] ?? null };
738
+ }
739
+ setStage(caseId, name, fields = {}) {
740
+ const loc = this.#caseLoc(caseId); if (!loc) return this.#stageRow(caseId, name, {});
741
+ const { data, title, summary, sections, exists } = this.#readAnalysis(loc.dir);
742
+ // A reset to pending (rerun) drops the stage entirely — its section and
743
+ // its state — so absence again means pending and a stale summary can't leak into
744
+ // the accumulated context. A fresh case's empty setStage(s, {}) writes nothing.
745
+ const resetting = fields.status === STATUS.PENDING;
746
+ const meaningful = Object.entries(fields).some(([k, v]) => v != null && !(k === 'status' && v === STATUS.PENDING));
747
+ if (!exists && !meaningful) return this.#stageRow(caseId, name, data, sections);
748
+ if (resetting) {
749
+ delete sections[name];
750
+ for (const k of ['status', 'score', 'model', 'harness', 'session', 'started', 'ended', 'error']) delete data[`${name}_${k}`];
751
+ } else {
752
+ const FK = { status: 'status', score: 'score', started_at: 'started', ended_at: 'ended', error: 'error', model: 'model', harness: 'harness', session: 'session' };
753
+ for (const [k, v] of Object.entries(fields)) {
754
+ const fk = FK[k]; if (!fk) continue; // `summary`/`stage` are body-owned; ignore
755
+ if (v == null) delete data[`${name}_${fk}`]; else data[`${name}_${fk}`] = v;
756
+ }
757
+ }
758
+ this.#writeAnalysis(loc.dir, { data, title, summary, sections });
759
+ return this.#stageRow(caseId, name, data, sections);
760
+ }
761
+ listStages(caseId) {
762
+ const loc = this.#caseLoc(caseId); if (!loc) return [];
763
+ const { data, sections } = this.#readAnalysis(loc.dir);
764
+ const out = [];
765
+ for (const stage of STAGES) if (data[`${stage}_status`] != null) out.push(this.#stageRow(caseId, stage, data, sections));
766
+ return out;
767
+ }
768
+
769
+ // ---- stage artifacts (full → analysis.md body section; extras → sidecar) ----
770
+ writeStage(pid, caseId, stage, files) {
771
+ const loc = this.#caseLoc(caseId); if (!loc) return null;
772
+ const { data, title, summary, sections } = this.#readAnalysis(loc.dir);
773
+ const sec = sections[stage] = sections[stage] || { full: null };
774
+ if (files['output_full.md'] != null) sec.full = files['output_full.md'];
775
+ for (const [name, content] of Object.entries(files)) {
776
+ if (name === 'output_full.md' || name === 'output_summary.md') continue;
777
+ const sp = sidecarPath(loc.dir, stage, name); ensureDir(dirname(sp)); writeFileSync(sp, content);
778
+ }
779
+ this.#writeAnalysis(loc.dir, { data, title, summary, sections });
780
+ return loc.dir;
781
+ }
782
+ readStage(pid, caseId, stage, name) {
783
+ const loc = this.#caseLoc(caseId); if (!loc) return null;
784
+ // Summaries were removed — `output_summary.md` falls back to the full section.
785
+ if (name === 'output_full.md' || name === 'output_summary.md') {
786
+ const { sections } = this.#readAnalysis(loc.dir);
787
+ return sections[stage]?.full ?? null;
788
+ }
789
+ const sp = sidecarPath(loc.dir, stage, name);
790
+ return existsSync(sp) ? readFileSync(sp, 'utf8') : null;
791
+ }
792
+
793
+ // ---- the raw record, for in-place editing in the app ----
794
+ //
795
+ // The app reads and writes the case's `analysis.md` as *raw text* (not through the
796
+ // structured #writeAnalysis renderer) so a human edit round-trips byte-for-byte —
797
+ // the record travels with the user's own git, so idle opens must never dirty it.
798
+ caseRoot(caseId) { const loc = this.#caseLoc(caseId); return loc ? { dir: loc.dir, vault: vaultPath(loc.pid) } : null; }
799
+ recordFile(caseId) { const loc = this.#caseLoc(caseId); return loc ? analysisFile(loc.dir) : null; }
800
+ // ---- the one-pager: an internal reverse-PR-FAQ (customer-voice) companion beside analysis.md ----
801
+ // Write the produced one-pager to `one-pager.md`, then re-render analysis.md so its
802
+ // "## One-pager" link section appears (the renderer emits it from the file's presence).
803
+ writeOnepager(caseId, body) {
804
+ const loc = this.#caseLoc(caseId); if (!loc) return null;
805
+ const f = onepagerFile(loc.dir); ensureDir(dirname(f));
806
+ // Carry the document kind in frontmatter (the app strips it before rendering);
807
+ // the body is the produced, unwrapped markdown starting at its `# headline`.
808
+ writeNote(f, { type: CASE_ONEPAGER }, String(body ?? '').replace(/\s+$/, '') + '\n');
809
+ this.#writeAnalysis(loc.dir, this.#readAnalysis(loc.dir)); // refresh the record's link
810
+ return f;
811
+ }
812
+ // The raw one-pager markdown (or null if it hasn't been produced for this case).
813
+ readOnepager(caseId) {
814
+ const loc = this.#caseLoc(caseId); if (!loc) return null;
815
+ const f = onepagerFile(loc.dir);
816
+ return existsSync(f) ? readFileSync(f, 'utf8') : null;
817
+ }
818
+ // The one-pager's absolute path + whether it exists — for the CLI/app to surface it.
819
+ onepagerRef(caseId) {
820
+ const loc = this.#caseLoc(caseId); if (!loc) return null;
821
+ const f = onepagerFile(loc.dir);
822
+ return { path: f, exists: existsSync(f) };
823
+ }
824
+ // ---- the score: the decorrelated Evaluator's recomputable verdict beside analysis.md ----
825
+ // Write the produced verdict to `score.md`, stamp the go/no-go number onto the record's
826
+ // top-level frontmatter (the inbox pill reads it), then re-render analysis.md so its
827
+ // "## Score" link section appears. Recomputable: `case score` overwrites both in place.
828
+ writeScore(caseId, body, number, { model, independent }) {
829
+ const loc = this.#caseLoc(caseId); if (!loc) return null;
830
+ const f = scoreFile(loc.dir); ensureDir(dirname(f));
831
+ const status = independent
832
+ ? `Independent evaluator · \`${model}\``
833
+ : `Same-session fallback · \`${model}\` · not an independent second opinion`;
834
+ const content = `> **Evaluation provenance:** ${status}.\n\n${String(body ?? '').replace(/\s+$/, '')}\n`;
835
+ writeNote(f, { type: CASE_SCORE, evaluator_model: model, evaluator_independent: independent }, content);
836
+ const rec = this.#readAnalysis(loc.dir);
837
+ if (number != null) rec.data.score = number; // the top-level go/no-go the inbox pill reads
838
+ this.#writeAnalysis(loc.dir, rec); // refresh the record's link + score
839
+ return f;
840
+ }
841
+ // The raw score markdown (or null if it hasn't been produced for this case).
842
+ readScore(caseId) {
843
+ const loc = this.#caseLoc(caseId); if (!loc) return null;
844
+ const f = scoreFile(loc.dir);
845
+ return existsSync(f) ? readFileSync(f, 'utf8') : null;
846
+ }
847
+ // The score's absolute path + whether it exists — for the CLI/app to surface it.
848
+ scoreRef(caseId) {
849
+ const loc = this.#caseLoc(caseId); if (!loc) return null;
850
+ const f = scoreFile(loc.dir);
851
+ return { path: f, exists: existsSync(f) };
852
+ }
853
+ // ---- the prototype: recomputable interactive companions beside analysis.md ----
854
+ // Write raw HTML to `prototype/[<surface>/]index.html` (flat for the default single surface;
855
+ // nested for each additional PRIMARY surface), then re-render analysis.md so its "## Prototype"
856
+ // link section reflects every built surface. Raw HTML is byte-preserved (never reflowed).
857
+ // Recomputable: rebuilding the same surface overwrites it. `surface` is a caller-slugified token.
858
+ writePrototype(caseId, html, surface = '') {
859
+ const loc = this.#caseLoc(caseId); if (!loc) return null;
860
+ const f = prototypeFile(loc.dir, surface); ensureDir(dirname(f));
861
+ writeFileSync(f, String(html ?? ''));
862
+ this.#writeAnalysis(loc.dir, this.#readAnalysis(loc.dir)); // refresh the record's links
863
+ return f;
864
+ }
865
+ // Every prototype a case has built — `[{ surface, path, rel, exists }]` (flat default first,
866
+ // then nested surfaces). Empty when none exist yet.
867
+ listPrototypes(caseId) {
868
+ const loc = this.#caseLoc(caseId); if (!loc) return [];
869
+ return listPrototypeFiles(loc.dir).map(p => ({ ...p, exists: true }));
870
+ }
871
+ // Resolve one case prototype. With a `surface` slug: that surface's `prototype/<slug>/index.html`.
872
+ // Without: the default — the analysis.md frontmatter `prototype` override (a relative/absolute
873
+ // path, or a full http(s) URL the app links out to), else the first built prototype (flat, else
874
+ // the first nested surface). Returns null for an unknown case.
875
+ prototypeRef(caseId, surface = '') {
876
+ const loc = this.#caseLoc(caseId); if (!loc) return null;
877
+ if (surface) { const abs = prototypeFile(loc.dir, surface); return { external: false, surface, path: abs, exists: existsSync(abs) }; }
878
+ const meta = readNote(analysisFile(loc.dir))?.data || {};
879
+ const override = meta.prototype != null && String(meta.prototype).trim();
880
+ if (override) {
881
+ const ref = String(override);
882
+ if (/^https?:\/\//i.test(ref)) return { external: true, url: ref, exists: true };
883
+ const abs = isAbsolute(ref) ? ref : join(loc.dir, ref);
884
+ return { external: false, path: abs, exists: existsSync(abs) };
885
+ }
886
+ const first = listPrototypeFiles(loc.dir)[0];
887
+ return first ? { external: false, surface: first.surface, path: first.path, exists: true }
888
+ : { external: false, path: prototypeFile(loc.dir), exists: false };
889
+ }
890
+ // Read the raw decision record (the whole analysis.md — the single file per case).
891
+ // `exists` reflects whether the record has any content beyond the bare title yet;
892
+ // `idea` carries the case summary so the app can show a grounded empty state.
893
+ readRecord(caseId) {
894
+ const loc = this.#caseLoc(caseId); if (!loc) return null;
895
+ const { data, title, summary, sections } = this.#readAnalysis(loc.dir);
896
+ const file = analysisFile(loc.dir);
897
+ let text = '';
898
+ try { text = readFileSync(file, 'utf8'); } catch { /* unreadable */ }
899
+ const hasContent = !!(summary || sections.highlights || STAGES.some(s => sections[s]?.full));
900
+ return {
901
+ id: String(data.id ?? caseId),
902
+ title: title || 'Decision record', file, exists: hasContent, text,
903
+ idea: summary || '',
904
+ };
905
+ }
906
+ // Persist a raw edit back to `analysis.md`. Creates the file if the case had no
907
+ // record yet (the first edit of a not-yet-analysed case). Returns false if unknown.
908
+ writeRecord(caseId, text) {
909
+ const loc = this.#caseLoc(caseId); if (!loc) return false;
910
+ const file = analysisFile(loc.dir); ensureDir(dirname(file));
911
+ writeFileSync(file, text.endsWith('\n') ? text : text + '\n');
912
+ return true;
913
+ }
914
+
915
+ // ---- in-record comments → project-level .sonorance/local/comments.jsonl ----
916
+ //
917
+ // The app lets a reader select any span of ANY file in the project (a case's
918
+ // analysis.md, a brief's brief.md, or any other file) and leave a comment — a question
919
+ // or a change request, GitHub-PR-review style. Comments live in ONE project-level file
920
+ // (`.sonorance/local/comments.jsonl`, gitignored machine-local state), NOT the case folder,
921
+ // so they work across cases, briefs and any file. Each line is `{ id, file,
922
+ // anchor:{quote,heading}, body, status:'open'|'resolved', ts, resolved_at?, note?, revised?,
923
+ // session? }`, where `file`
924
+ // is the annotated file's path **relative to the project root**. The agent works through
925
+ // the open comments with `/deliberate address` (which hands it the file path to edit),
926
+ // then resolves each. Resolved comments are hidden in the UI but kept for the record.
927
+ #commentsPath(pid) { return commentsFile(vaultPath(pid)); }
928
+ // Normalize a file reference (absolute or relative) to a project-root-relative POSIX path.
929
+ // Returns null if it escapes the project (defensive: never annotate outside the vault).
930
+ #relFile(pid, file) {
931
+ if (!file) return null;
932
+ const v = vaultPath(pid); if (!v) return null;
933
+ const abs = isAbsolute(file) ? file : join(v, file);
934
+ const rel = relative(v, abs).split(sep).join('/');
935
+ if (!rel || rel.startsWith('..') || isAbsolute(rel)) return null;
936
+ return rel;
937
+ }
938
+ #readComments(pid) {
939
+ try {
940
+ return readFileSync(this.#commentsPath(pid), 'utf8')
941
+ .trim().split('\n').filter(Boolean).map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
942
+ } catch { return []; }
943
+ }
944
+ #writeComments(pid, arr) {
945
+ const f = this.#commentsPath(pid); ensureDir(dirname(f));
946
+ atomicWrite(f, arr.length ? arr.map(c => JSON.stringify(c)).join('\n') + '\n' : '');
947
+ }
948
+ // Every comment in a project (for the agent's `address` batch and the resolve/delete lookups).
949
+ allComments(pid) { return this.#readComments(pid); }
950
+ // Normalize a file reference to the project-root-relative path used as the comment key —
951
+ // exposed so the server can hand the app the SAME relative path it stores/broadcasts.
952
+ relFile(pid, file) { return this.#relFile(pid, file); }
953
+ // Comments anchored to one file (what the app loads when a record opens).
954
+ comments(pid, file) { const rel = this.#relFile(pid, file); if (!rel) return []; return this.#readComments(pid).filter(c => c.file === rel); }
955
+ addComment(pid, file, entry) {
956
+ const rel = this.#relFile(pid, file); if (!rel) return null;
957
+ const rec = { id: randId(), file: rel, status: 'open', ts: now(), ...entry, };
958
+ rec.file = rel; // keep the resolved path even if entry carried one
959
+ const all = this.#readComments(pid); all.push(rec); this.#writeComments(pid, all);
960
+ return rec;
961
+ }
962
+ resolveComment(pid, commentId, patch) {
963
+ const all = this.#readComments(pid); let hit = null;
964
+ const next = all.map(c => (c.id === commentId ? (hit = { ...c, ...patch }) : c));
965
+ if (!hit) return null;
966
+ this.#writeComments(pid, next);
967
+ return hit;
968
+ }
969
+ // The reader deletes their own comment outright (distinct from the agent resolving it):
970
+ // the line is removed from comments.jsonl entirely. Returns the removed record, or null.
971
+ deleteComment(pid, commentId) {
972
+ const all = this.#readComments(pid);
973
+ const hit = all.find(c => c.id === commentId); if (!hit) return null;
974
+ this.#writeComments(pid, all.filter(c => c.id !== commentId));
975
+ return hit;
976
+ }
977
+ // Drop every comment anchored to a file inside `absDir` — called when a case/brief folder is
978
+ // deleted, so its annotations don't linger as orphans in comments.jsonl (pointing at a file
979
+ // that no longer exists).
980
+ #pruneCommentsUnder(pid, absDir) {
981
+ const rel = this.#relFile(pid, absDir);
982
+ if (!rel) return;
983
+ const all = this.#readComments(pid);
984
+ const kept = all.filter(c => { const f = String(c.file || ''); return f !== rel && !f.startsWith(rel + '/'); });
985
+ if (kept.length !== all.length) this.#writeComments(pid, kept);
986
+ }
987
+
988
+ // ---- run log (producer/critic verdicts) → per-case log.jsonl ----
989
+ log(caseId, stage, role, model, verdict) {
990
+ const loc = this.#caseLoc(caseId); if (!loc) return;
991
+ try { appendFileSync(join(loc.dir, 'log.jsonl'), JSON.stringify({ ts: now(), stage, role, model, verdict: verdict || null }) + '\n'); } catch {}
992
+ }
993
+ runs(caseId) {
994
+ const loc = this.#caseLoc(caseId); if (!loc) return [];
995
+ try { return readFileSync(join(loc.dir, 'log.jsonl'), 'utf8').trim().split('\n').filter(Boolean).map(l => ({ case_id: +caseId, ...JSON.parse(l) })); } catch { return []; }
996
+ }
997
+
998
+ // Build the (globally-unique) caseId → folder index by scanning every registered
999
+ // vault and reading each `analysis.md`'s frontmatter id (folders are date/slug-prefixed,
1000
+ // so the id is not recoverable from the folder name alone).
1001
+ reindex() {
1002
+ this.caseIndex.clear();
1003
+ this.briefIndex.clear();
1004
+ this.readoutIndex.clear();
1005
+ this.matchupIndex.clear();
1006
+ for (const pid of this.#projectIds()) {
1007
+ const root = casesDir(vaultPath(pid));
1008
+ if (existsSync(root)) {
1009
+ for (const name of readdirSync(root)) {
1010
+ if (name.startsWith('.')) continue;
1011
+ const n = readNote(analysisFile(join(root, name)));
1012
+ if (n?.data?.id != null) this.caseIndex.set(String(n.data.id), { pid, dir: join(root, name) });
1013
+ }
1014
+ }
1015
+ const broot = briefsDir(vaultPath(pid));
1016
+ if (existsSync(broot)) {
1017
+ for (const name of readdirSync(broot)) {
1018
+ if (name.startsWith('.')) continue;
1019
+ const n = readNote(briefFile(join(broot, name)));
1020
+ if (n?.data?.id != null) this.briefIndex.set(String(n.data.id), { pid, dir: join(broot, name) });
1021
+ }
1022
+ }
1023
+ const rroot = readoutsDir(vaultPath(pid));
1024
+ if (existsSync(rroot)) {
1025
+ for (const name of readdirSync(rroot)) {
1026
+ if (name.startsWith('.')) continue;
1027
+ const n = readNote(readoutFile(join(rroot, name)));
1028
+ if (n?.data?.id != null) this.readoutIndex.set(String(n.data.id), { pid, dir: join(rroot, name) });
1029
+ }
1030
+ }
1031
+ const mroot = matchupsDir(vaultPath(pid));
1032
+ if (existsSync(mroot)) {
1033
+ for (const name of readdirSync(mroot)) {
1034
+ if (name.startsWith('.')) continue;
1035
+ const n = readNote(matchupFile(join(mroot, name)));
1036
+ if (n?.data?.id != null) this.matchupIndex.set(String(n.data.id), { pid, dir: join(mroot, name) });
1037
+ }
1038
+ }
1039
+ }
1040
+ }
1041
+ }
1042
+
1043
+ export function openVault() { return new VaultStore(); }