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.
- package/LICENSE +174 -0
- package/README.md +103 -0
- package/build/icon.png +0 -0
- package/package.json +86 -0
- package/skill/SKILL.md +41 -0
- package/skill/scripts/sonorance.mjs +31 -0
- package/src/azure-monitor.mjs +221 -0
- package/src/cli.mjs +315 -0
- package/src/comment-client.mjs +75 -0
- package/src/engine-default.mjs +136 -0
- package/src/feedback.mjs +151 -0
- package/src/gitignore.mjs +27 -0
- package/src/grammar.mjs +55 -0
- package/src/identity.mjs +126 -0
- package/src/otlp.mjs +166 -0
- package/src/plugins/deliberate/contribute.mjs +28 -0
- package/src/plugins/deliberate/domain.mjs +40 -0
- package/src/plugins/deliberate/frontmatter.mjs +85 -0
- package/src/plugins/deliberate/gitignore.mjs +21 -0
- package/src/plugins/deliberate/kinds.mjs +44 -0
- package/src/plugins/deliberate/markdown.mjs +42 -0
- package/src/plugins/deliberate/paths.mjs +245 -0
- package/src/plugins/deliberate/stages.mjs +27 -0
- package/src/plugins/deliberate/vault.mjs +1043 -0
- package/src/plugins.mjs +91 -0
- package/src/release-config.mjs +2 -0
- package/src/scrubber.mjs +64 -0
- package/src/server/index.mjs +993 -0
- package/src/sources.mjs +80 -0
- package/src/telemetry-schema.mjs +187 -0
- package/src/telemetry.mjs +390 -0
- package/src/ui/active-line.mjs +42 -0
- package/src/ui/app.js +3553 -0
- package/src/ui/at-mention.mjs +67 -0
- package/src/ui/comments-plugin.mjs +107 -0
- package/src/ui/diff-plugin.mjs +73 -0
- package/src/ui/editor.mjs +210 -0
- package/src/ui/index.html +1723 -0
- package/src/ui/md.mjs +233 -0
- package/src/ui/paste-md.mjs +54 -0
- package/src/ui/shell.html +1374 -0
- package/src/ui/slash.mjs +122 -0
- 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
|
+
}
|