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,85 @@
1
+ /**
2
+ * frontmatter.mjs — minimal YAML-ish frontmatter for Deliberate notes.
3
+ *
4
+ * Deliberate stores everything as plain Markdown/JSON files (no database), and a
5
+ * "note" is a file with an optional `--- ... ---` frontmatter block followed by a
6
+ * Markdown body. This is a deliberately tiny, zero-dependency parser/serialiser
7
+ * tuned for the flat key/scalar frontmatter we write ourselves, plus one
8
+ * multi-line block scalar (`summary: |`) for the condensed stage summary.
9
+ *
10
+ * It is not a general YAML implementation — it only needs to round-trip the
11
+ * shapes this codebase produces, while staying readable if a human edits a note.
12
+ */
13
+
14
+ // Scalars we emit unquoted vs. quoted. Quote when the value is empty or could be
15
+ // mis-parsed (leading/trailing space, or a leading YAML-significant char).
16
+ const needsQuote = (s) => s === '' || /^\s|\s$/.test(s) || /^[-?:,\[\]{}#&*!|>'"%@`]/.test(s) || /:\s/.test(s) || s.includes(': ');
17
+
18
+ function serializeScalar(v) {
19
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
20
+ const s = String(v);
21
+ return needsQuote(s) ? `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"` : s;
22
+ }
23
+
24
+ function parseScalar(raw) {
25
+ if (raw === '') return '';
26
+ if (/^".*"$/.test(raw)) return raw.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
27
+ if (raw === 'true') return true;
28
+ if (raw === 'false') return false;
29
+ if (raw === 'null' || raw === '~') return null;
30
+ if (/^-?\d+$/.test(raw)) return parseInt(raw, 10);
31
+ if (/^-?\d*\.\d+$/.test(raw)) return parseFloat(raw);
32
+ return raw;
33
+ }
34
+
35
+ /**
36
+ * serialize(data, body) → the full note text (frontmatter block + body).
37
+ * Keys whose value is null/undefined are omitted. Multi-line string values are
38
+ * written as `key: |` block scalars.
39
+ */
40
+ export function serialize(data = {}, body = '') {
41
+ const lines = [];
42
+ for (const [k, v] of Object.entries(data)) {
43
+ if (v === null || v === undefined) continue;
44
+ if (typeof v === 'string' && v.includes('\n')) {
45
+ lines.push(`${k}: |`);
46
+ for (const l of v.replace(/\n+$/, '').split('\n')) lines.push(' ' + l);
47
+ } else {
48
+ lines.push(`${k}: ${serializeScalar(v)}`);
49
+ }
50
+ }
51
+ const fm = `---\n${lines.join('\n')}\n---\n`;
52
+ const b = (body || '').replace(/^\n+/, '');
53
+ return b ? `${fm}\n${b.replace(/\s+$/, '')}\n` : fm;
54
+ }
55
+
56
+ /**
57
+ * parse(text) → { data, body }. Files with no leading `---` frontmatter return
58
+ * `{ data: {}, body: text }` so plain Markdown still round-trips.
59
+ */
60
+ export function parse(text = '') {
61
+ if (!/^---\r?\n/.test(text)) return { data: {}, body: text };
62
+ const lines = text.split('\n');
63
+ let end = -1;
64
+ for (let i = 1; i < lines.length; i++) { if (lines[i].trim() === '---') { end = i; break; } }
65
+ if (end === -1) return { data: {}, body: text };
66
+
67
+ const data = {};
68
+ let i = 1;
69
+ while (i < end) {
70
+ const m = lines[i].match(/^([A-Za-z0-9_-]+):\s?(.*)$/);
71
+ if (!m) { i++; continue; }
72
+ const [, key, rest] = m;
73
+ if (rest === '|' || rest === '|-') {
74
+ const block = [];
75
+ i++;
76
+ while (i < end && (lines[i] === '' || /^\s{2,}/.test(lines[i]))) { block.push(lines[i].replace(/^ {2}/, '')); i++; }
77
+ data[key] = block.join('\n').replace(/\n+$/, '');
78
+ } else {
79
+ data[key] = parseScalar(rest.trim());
80
+ i++;
81
+ }
82
+ }
83
+ const body = lines.slice(end + 1).join('\n').replace(/^\n+/, '').replace(/\s+$/, '');
84
+ return { data, body };
85
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * gitignore.mjs — which machine-state paths a Deliberate vault should keep out of git.
3
+ *
4
+ * Shared by the CLI `init` and by `app-boot` (so `deliberate serve` ignores it too, the moment
5
+ * it initiates the project). The entries: the machine-local `.sonorance/local/` dir (runtime
6
+ * state — editor state, serve pointer, comments) plus every hidden (dot-prefixed) subfolder a
7
+ * skill may write under `deliberate/`. The committed config at `.sonorance/` (identity, sources,
8
+ * plugins) stays in git. The actual `.gitignore` write is the app's generic `ensureGitignore`
9
+ * (it never creates a .gitignore, and is idempotent).
10
+ */
11
+ import { readdirSync } from 'node:fs';
12
+ import { deliberateDir } from './paths.mjs';
13
+
14
+ export function vaultIgnoreEntries(root) {
15
+ const entries = ['.sonorance/local/'];
16
+ try {
17
+ for (const e of readdirSync(deliberateDir(root), { withFileTypes: true }))
18
+ if (e.isDirectory() && e.name.startsWith('.')) entries.push(`deliberate/${e.name}/`);
19
+ } catch { /* no deliberate/ dir yet */ }
20
+ return entries;
21
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * kinds.mjs — the declarative document-kind registry.
3
+ *
4
+ * This is the SINGLE place that maps a document `type` (its frontmatter `type:` field)
5
+ * to how a host surface presents it. It is the thin, Deliberate-specific layer that the
6
+ * otherwise domain-agnostic app (src/server + src/ui) consumes as *data* — the
7
+ * app core knows only `type` strings + the small declarative vocabulary below
8
+ * (which inbox section, what badge, what subtitle), never `case`/`brief`/`score` logic.
9
+ *
10
+ * Types are hierarchical + namespaced (`deliberate/<entity>/<document>`): a "case" is the
11
+ * folder that groups its documents; the `type` identifies the DOCUMENT.
12
+ */
13
+ export const CASE_ANALYSIS = 'deliberate/case/analysis'; // the decision record (analysis.md)
14
+ export const CASE_ONEPAGER = 'deliberate/case/one-pager'; // the internal reverse-PR-FAQ companion (customer voice)
15
+ export const CASE_SCORE = 'deliberate/case/score'; // the decorrelated Evaluator's verdict (score.md)
16
+ export const BRIEF = 'deliberate/brief'; // a landscape brief (brief.md)
17
+ export const READOUT = 'deliberate/readout'; // a product readout (readout.md)
18
+ export const MATCHUP = 'deliberate/matchup'; // a single-competitor head-to-head (matchup.md)
19
+
20
+ // Each descriptor: { type, inbox?, addressing? }. A `type` with no `inbox` block (e.g. the
21
+ // one-pager companion) simply isn't surfaced on the home page. The `inbox` vocabulary is
22
+ // deliberately small and declarative so the app renders it generically:
23
+ // section the heading the documents group under
24
+ // source which /api/state collection to draw from ('cases' | 'briefs' | 'readouts' | 'matchups')
25
+ // limit how many recent items to show
26
+ // badge a widget from the generic vocabulary: 'score' (verdict pill) | 'brief' | 'readout' | 'matchup' (icons)
27
+ // subtitle a per-row secondary line: a field name, or 'const:<text>' for a fixed label
28
+ // `addressing` is an optional one-line note the comment bridge attaches to each open comment
29
+ // on a file of that type, so the agent working through `/deliberate address` gets the
30
+ // document-specific care instruction (generic files carry no note).
31
+ export const KINDS = [
32
+ { type: CASE_ANALYSIS, inbox: { section: 'Recent cases', source: 'cases', limit: 5, badge: 'score', subtitle: 'state' },
33
+ addressing: 'This is a case decision record — keep the sibling one-pager consistent with any change you make here.' },
34
+ { type: BRIEF, inbox: { section: 'Recent briefs', source: 'briefs', limit: 3, badge: 'brief', subtitle: 'const:Landscape brief' } },
35
+ { type: READOUT, inbox: { section: 'Recent readouts', source: 'readouts', limit: 3, badge: 'readout', subtitle: 'const:Product readout' },
36
+ addressing: 'This is a product readout — preserve its report-level period, source links, exact metric definitions, representative customer evidence, and the distinction between observation, hypothesis, and causality.' },
37
+ { type: MATCHUP, inbox: { section: 'Recent matchups', source: 'matchups', limit: 3, badge: 'matchup', subtitle: 'competitor' },
38
+ addressing: 'This is a competitive matchup (a single-rival head-to-head) — keep it grounded and point-in-time; re-run `matchup <competitor>` to refresh rather than letting claims go stale.' },
39
+ { type: CASE_ONEPAGER, addressing: 'This is a case one-pager (an internal reverse PR-FAQ) — preserve the first-person voice of the customer and keep it consistent with the analysis.' },
40
+ { type: CASE_SCORE, addressing: 'This is the decorrelated Evaluator’s score for the case — if the analysis changed, re-run `case score` to refresh it rather than editing the number by hand.' },
41
+ ];
42
+
43
+ // Look up a descriptor by its `type` string (or null).
44
+ export const kindFor = (type) => KINDS.find(k => k.type === type) || null;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * markdown.mjs — tiny, dependency-free Markdown helpers used when persisting generated
3
+ * artifacts (stage records, briefs, highlights, the case summary).
4
+ *
5
+ * `unwrapProse` reflows hard-wrapped prose. Producers (and templates) often break a single
6
+ * paragraph across several ~fixed-width source lines; in Markdown a lone newline inside a
7
+ * paragraph is only a *soft* break (a space), and the app even renders it as a
8
+ * literal `<br>`, so the saved file reads as if it were artificially chopped up. We join
9
+ * each paragraph / list-item / block-quote back into one logical line, while leaving the
10
+ * real block structure intact: blank lines, ATX headings, list markers (one item per line),
11
+ * GFM table rows, thematic breaks, and fenced/indented code blocks are never merged.
12
+ */
13
+
14
+ const isBlank = (l) => /^\s*$/.test(l);
15
+ const isFence = (l) => /^\s*(?:`{3,}|~{3,})/.test(l);
16
+ const isHeading = (l) => /^\s{0,3}#{1,6}\s/.test(l);
17
+ const isHr = (l) => /^\s{0,3}([-*_])[ \t]*(?:\1[ \t]*){2,}$/.test(l);
18
+ const isTableRow = (l) => /^\s*\|/.test(l); // GFM rows are single-line and start with `|`
19
+ const isListItem = (l) => /^\s*(?:[-*+]|\d+[.)])\s+/.test(l);
20
+ const isQuote = (l) => /^\s*>/.test(l);
21
+ const isIndentedCode = (l) => /^(?: {4,}|\t)/.test(l);
22
+
23
+ export function unwrapProse(md) {
24
+ const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
25
+ const out = [];
26
+ let buf = null; // the logical line being accumulated (a paragraph / list item / quote)
27
+ let fence = false;
28
+ const flush = () => { if (buf !== null) { out.push(buf); buf = null; } };
29
+ for (const line of lines) {
30
+ if (isFence(line)) { flush(); out.push(line); fence = !fence; continue; }
31
+ if (fence) { out.push(line); continue; } // inside a code fence — verbatim
32
+ if (isBlank(line)) { flush(); out.push(''); continue; }
33
+ if (isHeading(line) || isHr(line) || isTableRow(line)) { flush(); out.push(line); continue; }
34
+ if (buf === null && isIndentedCode(line)) { out.push(line); continue; } // a standalone indented code line
35
+ if (isListItem(line) || isQuote(line)) { flush(); buf = line.replace(/\s+$/, ''); continue; }
36
+ // a plain text line: continue the current paragraph/item, or start a new paragraph
37
+ if (buf === null) buf = line.replace(/\s+$/, '');
38
+ else buf = buf.replace(/\s+$/, '') + ' ' + line.trim();
39
+ }
40
+ flush();
41
+ return out.join('\n');
42
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * paths.mjs — resolve Deliberate's on-disk locations.
3
+ *
4
+ * Deliberate is files-first: there is no database. Two kinds of location:
5
+ *
6
+ * 1. App-data (`~/.sonorance`, override with SONORANCE_HOME): global settings,
7
+ * the current-project pointer, the vault registry, the disposable index
8
+ * cache, and the log. Nothing here is a source of truth except the registry
9
+ * of where vaults live.
10
+ * 2. A **vault** — a plain folder that IS a project: a visible `deliberate/` folder
11
+ * holding the human-curated context (`deliberate/context/`) and the case/brief
12
+ * records, PLUS a hidden `.sonorance/` sibling holding the Sonorance platform state.
13
+ * The COMMITTED platform config (project identity, grounding sources, enabled plugins)
14
+ * sits at `.sonorance/`; disposable machine-local state (editor state, the running-server
15
+ * pointer, comments) sits under `.sonorance/local/`, which `init`/`serve` gitignore. By
16
+ * A project's vault is an explicitly opened folder (usually a repo root, so the repo gains a
17
+ * `deliberate/` folder + a hidden `.sonorance/`) registered in the app-data `vaults` map.
18
+ */
19
+ import { homedir } from 'node:os';
20
+ import { join, dirname } from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { readFileSync, existsSync, readdirSync } from 'node:fs';
23
+ import { parse } from './frontmatter.mjs';
24
+ import { createRegistry } from '../../vault-registry.mjs';
25
+
26
+ // The Deliberate checkout root (…/src/engine/paths.mjs → up two). Used to resolve
27
+ // repo-relative assets shipped with the engine (e.g. config-declared output templates).
28
+ const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
29
+ // Resolve a repo-relative asset path (e.g. a `roles/config.yaml` templates entry) to
30
+ // absolute. The init scaffolds (output-template-product.md / -competitors.md / -ecosystem.md) live under
31
+ // roles/initiator/init/ and are declared in config.yaml; the store reads them via this.
32
+ export const repoFile = (rel) => join(REPO_ROOT, rel);
33
+
34
+ // ---- app-data ----
35
+ // ONE platform home, shared with the standalone Sonorance editor: `~/.sonorance` (override with
36
+ // SONORANCE_HOME). Deliberate is a skill that runs on Sonorance vaults, so it stores its
37
+ // registry + projects under the same home rather than a separate `~/.sonorance`.
38
+ export function appHome() {
39
+ return process.env.SONORANCE_HOME || join(homedir(), '.sonorance');
40
+ }
41
+ export const appConfigPath = () => join(appHome(), 'config.json');
42
+ export const logFile = () => join(appHome(), 'sonorance.log');
43
+ export const cacheDir = () => join(appHome(), 'cache');
44
+
45
+ // Read the app-data config (background flag + any host-owned keys). The vault registry itself
46
+ // (vaults + current) is owned by the shared `vault-registry` module; never throws.
47
+ export function readAppConfig() {
48
+ try { return JSON.parse(readFileSync(appConfigPath(), 'utf8')); } catch { return {}; }
49
+ }
50
+
51
+ // The shared multi-vault registry (Sonorance-owned; keyed by absolute path). Deliberate reuses
52
+ // it so the standalone editor and Deliberate see the SAME `~/.sonorance/config.json` vault list.
53
+ const registry = createRegistry();
54
+
55
+ // Resolve a registered project id to its explicit vault folder. Missing ids are errors:
56
+ // Deliberate never fabricates a hidden default vault.
57
+ export function vaultPath(id) {
58
+ const dir = registry.dirById(id);
59
+ if (!dir) throw new Error(`Unknown vault: ${id}`);
60
+ return dir;
61
+ }
62
+
63
+ // ---- within a vault ----
64
+ // Sonorance is the platform; Deliberate is one skill that runs on it. A vault therefore has
65
+ // two top-level parts (the recommendation in spec/6-brand-platform/config-layout.md):
66
+ // deliberate/ VISIBLE brand content — the deliverables the user reads/commits:
67
+ // deliberate/context/ human-curated project context (product.md, competitors.md, ecosystem.md, …)
68
+ // deliberate/cases/<case>/ the case records (<case> = YYYY-MM-DD-slug)
69
+ // deliberate/briefs/<date>/ landscape briefs
70
+ // .sonorance/ HIDDEN platform state (see below) — sibling to deliberate/.
71
+ // For an in-repo project the vault IS the repo root, so the repo gains a `deliberate/` folder
72
+ // and a hidden `.sonorance/`.
73
+ export const deliberateDir = (vault) => join(vault, 'deliberate');
74
+ export const contextDir = (vault) => join(deliberateDir(vault), 'context');
75
+ export const contextFile = (vault) => join(contextDir(vault), 'product.md');
76
+ export const competitorsFile = (vault) => join(contextDir(vault), 'competitors.md');
77
+ export const ecosystemFile = (vault) => join(contextDir(vault), 'ecosystem.md');
78
+ export const casesDir = (vault) => join(deliberateDir(vault), 'cases');
79
+
80
+ // ---- platform state (Sonorance-owned, shared across skills) ----
81
+ // Cross-skill machine state lives in a hidden `.sonorance/` at the vault root (sibling to the
82
+ // visible `deliberate/` brand folder). `sonorance init` writes
83
+ // the project identity + shared grounding here, and the generic comment/serve plumbing lives here
84
+ // too, so a folder opened by the standalone app and by Deliberate share ONE config.
85
+ export const sonoranceDir = (vault) => join(vault, '.sonorance');
86
+ export const vaultConfigPath = (vault) => join(sonoranceDir(vault), 'config.json'); // the ONE per-vault identity file (id/name/repo/created_at + settings) — COMMITTED
87
+ // Grounding sources — a plain-Markdown list the user can hand-edit (`.sonorance/sources.md`).
88
+ // COMMITTED alongside config.json: the curated evidence base is project data, not machine state.
89
+ export const sourcesFile = (vault) => join(sonoranceDir(vault), 'sources.md');
90
+ export const pluginsFile = (vault) => join(sonoranceDir(vault), 'plugins.json'); // the vault's enabled plugins (flavor/integrations) — COMMITTED
91
+ // ---- machine-local state (gitignored) ----
92
+ // Everything that captures per-machine / per-session runtime state lives under a single hidden
93
+ // `.sonorance/local/` subfolder that `init`/`serve` add to .gitignore — so the COMMITTED config
94
+ // (identity, sources, plugins) stays in git while disposable state never is.
95
+ export const sonoranceLocalDir = (vault) => join(sonoranceDir(vault), 'local');
96
+ export const vaultStatePath = (vault) => join(sonoranceLocalDir(vault), 'state.json'); // disposable editor state (open tabs, Explorer) — regenerable
97
+ // Project-level in-record comments (annotations on ANY file) — a GENERIC Sonorance feature. Each
98
+ // line records the file it annotates, so comments work across cases, briefs, and any other file.
99
+ export const commentsFile = (vault) => join(sonoranceLocalDir(vault), 'comments.jsonl');
100
+ // The running app's coordinates ({ port, pid, version, ts }) for THIS vault, written by `serve` so
101
+ // `address`/`resolve` (run in the project folder) reach the RIGHT server instead of guessing the
102
+ // default port — the fix for multi-instance / non-default-port skew.
103
+ export const serveInfoPath = (vault) => join(sonoranceLocalDir(vault), 'serve.json');
104
+
105
+ // A case folder is `YYYY-MM-DD-slug` (no number — the per-project #N lives in the
106
+ // case's frontmatter, not the path). The date prefix sorts a listing chronologically.
107
+ export const caseDirName = (slug, ts) => `${ymd(ts)}-${slug || 'case'}`;
108
+ export const caseDir = (vault, slug, ts) => join(casesDir(vault), caseDirName(slug, ts));
109
+
110
+ // Local-date stamp (YYYY-MM-DD) used to prefix case folders so a directory
111
+ // listing sorts chronologically and each case is self-dating.
112
+ const pad2 = (n) => String(n).padStart(2, '0');
113
+ export function ymd(ts) {
114
+ const d = new Date(ts || Date.now());
115
+ return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
116
+ }
117
+
118
+ // Find an existing case's folder by its (globally-unique) internal id. Folders are
119
+ // `YYYY-MM-DD-slug`, so we resolve by reading each `analysis.md`'s frontmatter id
120
+ // rather than parsing the name — robust to the date/slug prefix and to retitle renames.
121
+ // (Cases are no longer a separate `case.md` file — the whole record, metadata included,
122
+ // is the single `analysis.md`.)
123
+ export function findCaseDir(vault, id) {
124
+ const root = casesDir(vault);
125
+ if (!existsSync(root)) return null;
126
+ for (const name of readdirSync(root)) {
127
+ if (name.startsWith('.')) continue;
128
+ try {
129
+ const { data } = parse(readFileSync(join(root, name, 'analysis.md'), 'utf8'));
130
+ if (String(data.id) === String(id)) return join(root, name);
131
+ } catch { /* not a case folder */ }
132
+ }
133
+ return null;
134
+ }
135
+
136
+ // The slug portion of a case folder name (`YYYY-MM-DD-slug` → `slug`). The slug is
137
+ // no longer stored in frontmatter — the folder name is its source of truth.
138
+ export const slugOfCaseDir = (dirName) => String(dirName || '').replace(/^\d{4}-\d{2}-\d{2}-/, '') || 'case';
139
+
140
+ // A case's whole record is one combined document: `analysis.md` (the human-facing
141
+ // decision record — a section per stage, in funnel order). Binary/large sidecars a
142
+ // stage produces (the prototype `index.html`) live in a per-stage
143
+ // subfolder the record links to, e.g. `prototype/index.html`.
144
+ export const analysisFile = (caseDirPath) => join(caseDirPath, 'analysis.md');
145
+ // The internal reverse-PR-FAQ companion to analysis.md (the Analyst's PR/FAQ-style,
146
+ // narrative + FAQ), written alongside the decision record at the case-folder root.
147
+ export const onepagerFile = (caseDirPath) => join(caseDirPath, 'one-pager.md');
148
+ // The decorrelated Evaluator's verdict — a recomputable companion beside analysis.md.
149
+ // Kept out of the record so a revised analysis can be re-scored (via `case score`)
150
+ // without rewriting the decision record itself.
151
+ export const scoreFile = (caseDirPath) => join(caseDirPath, 'score.md');
152
+ // The Prototype — a recomputable companion built on request (never auto-run).
153
+ // Like the score, it lives outside the record; the record only links to it when present.
154
+ // A case can carry one prototype per PRIMARY surface (init marks them): the single-surface
155
+ // default stays flat at `prototype/index.html`, and each additional primary surface nests
156
+ // under its slug at `prototype/<surface>/index.html`. One resolver yields both — an empty
157
+ // (default) surface collapses to the flat path via join()'s empty-segment handling.
158
+ export const prototypeDir = (caseDirPath) => join(caseDirPath, 'prototype');
159
+ export const prototypeFile = (caseDirPath, surface = '') => join(caseDirPath, 'prototype', surface, 'index.html');
160
+ export const sidecarDir = (caseDirPath, stage) => join(caseDirPath, stage);
161
+ export const sidecarPath = (caseDirPath, stage, name) => join(sidecarDir(caseDirPath, stage), name);
162
+
163
+ // ---- briefs ----
164
+ // A brief is a project-scoped, time-windowed landscape report (NOT a case): the
165
+ // competitive + market changes since the last brief. Briefs live beside cases under
166
+ // `deliberate/briefs/<YYYY-MM-DD>/brief.md` — the folder is the generation date so a
167
+ // listing sorts chronologically; a stable hash id (the only handle) lives in the
168
+ // brief's frontmatter (like cases), not the folder name.
169
+ export const briefsDir = (vault) => join(deliberateDir(vault), 'briefs');
170
+ export const briefDirName = (ts) => ymd(ts);
171
+ export const briefDir = (vault, ts) => join(briefsDir(vault), briefDirName(ts));
172
+ export const briefFile = (briefDirPath) => join(briefDirPath, 'brief.md');
173
+
174
+ // Find an existing brief's folder by its (globally-unique) internal id — resolve by
175
+ // reading each brief.md's frontmatter id (robust to the date-prefixed folder name).
176
+ export function findBriefDir(vault, id) {
177
+ const root = briefsDir(vault);
178
+ if (!existsSync(root)) return null;
179
+ for (const name of readdirSync(root)) {
180
+ if (name.startsWith('.')) continue;
181
+ try {
182
+ const { data } = parse(readFileSync(join(root, name, 'brief.md'), 'utf8'));
183
+ if (String(data.id) === String(id)) return join(root, name);
184
+ } catch { /* not a brief folder */ }
185
+ }
186
+ return null;
187
+ }
188
+
189
+ // ---- readouts ----
190
+ // A product readout is a project-scoped, date-keyed synthesis of configured metrics,
191
+ // customer evidence, and warranted actions. Like briefs, each run creates a durable
192
+ // time-series artifact with a stable id in frontmatter.
193
+ export const readoutsDir = (vault) => join(deliberateDir(vault), 'readouts');
194
+ export const readoutDirName = (ts) => ymd(ts);
195
+ export const readoutDir = (vault, ts) => join(readoutsDir(vault), readoutDirName(ts));
196
+ export const readoutFile = (readoutDirPath) => join(readoutDirPath, 'readout.md');
197
+ export const readoutChartsDir = (readoutDirPath) => join(readoutDirPath, 'charts');
198
+ export const readoutChartFile = (readoutDirPath, name) => join(readoutChartsDir(readoutDirPath), name);
199
+
200
+ export function findReadoutDir(vault, id) {
201
+ const root = readoutsDir(vault);
202
+ if (!existsSync(root)) return null;
203
+ for (const name of readdirSync(root)) {
204
+ if (name.startsWith('.')) continue;
205
+ try {
206
+ const { data } = parse(readFileSync(join(root, name, 'readout.md'), 'utf8'));
207
+ if (String(data.id) === String(id)) return join(root, name);
208
+ } catch { /* not a readout folder */ }
209
+ }
210
+ return null;
211
+ }
212
+
213
+ // ---- matchups ----
214
+ // A matchup is a project-scoped, single-competitor head-to-head (NOT a case, NOT a brief):
215
+ // a full point-in-time read of ONE named rival against us. Matchups live beside cases and
216
+ // briefs under `deliberate/matchups/<competitor-slug>/matchup.md` — the folder is the RIVAL's
217
+ // slug (not a date), so there is exactly one canonical matchup per competitor, refreshed in
218
+ // place. A stable hash id (the only cross-collaborator handle) + the `as_of` date live in the
219
+ // matchup's frontmatter; the folder slug is the human handle.
220
+ export const matchupsDir = (vault) => join(deliberateDir(vault), 'matchups');
221
+ export const matchupDir = (vault, slug) => join(matchupsDir(vault), slug || 'rival');
222
+ export const matchupFile = (matchupDirPath) => join(matchupDirPath, 'matchup.md');
223
+
224
+ // Find an existing matchup's folder by its (globally-unique) internal id — resolve by
225
+ // reading each matchup.md's frontmatter id (robust to the slug folder name / renames).
226
+ export function findMatchupDir(vault, id) {
227
+ const root = matchupsDir(vault);
228
+ if (!existsSync(root)) return null;
229
+ for (const name of readdirSync(root)) {
230
+ if (name.startsWith('.')) continue;
231
+ try {
232
+ const { data } = parse(readFileSync(join(root, name, 'matchup.md'), 'utf8'));
233
+ if (String(data.id) === String(id)) return join(root, name);
234
+ } catch { /* not a matchup folder */ }
235
+ }
236
+ return null;
237
+ }
238
+
239
+ // Find an existing matchup's folder by its competitor SLUG (the folder name) — the key that
240
+ // makes a matchup refresh-in-place: re-running the same rival updates its one canonical doc.
241
+ export function findMatchupDirBySlug(vault, slug) {
242
+ if (!slug) return null;
243
+ const dir = matchupDir(vault, slug);
244
+ return existsSync(matchupFile(dir)) ? dir : null;
245
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * stages.mjs — the linear analyst funnel: the ordered stages the host authors.
3
+ *
4
+ * The workflow is defined by the `/deliberate` skill; these stages are just the
5
+ * persistence spine (the parts are skill playbooks + templates, not engine agents).
6
+ * These constants define the persisted artifact order and companion document names.
7
+ */
8
+ export const STAGES = ['frame', 'shape', 'launch'];
9
+
10
+ // The Evaluator: the decorrelated go/no-go the host runs as an ISOLATED, cross-vendor
11
+ // sub-agent (never anchored by the Analyst). It is NOT in STAGES — the score is its own
12
+ // recomputable companion artifact (`score.md`), refreshed via `case score` after revisions.
13
+ export const EVALUATOR_STAGE = 'score';
14
+
15
+ // The Prototyper: the interactive mock of the primary journey the host builds in-session.
16
+ // It is NOT in STAGES — the prototype is its own recomputable companion artifact
17
+ // (`prototype/index.html`), built on request via `case prototype` (never auto-run).
18
+ export const PROTOTYPE_STAGE = 'prototype';
19
+
20
+ export function nextStage(name) {
21
+ const i = STAGES.indexOf(name);
22
+ return i < 0 || i === STAGES.length - 1 ? null : STAGES[i + 1];
23
+ }
24
+ export function followingStages(name) {
25
+ const i = STAGES.indexOf(name);
26
+ return i < 0 ? [] : STAGES.slice(i);
27
+ }