deliberate-cli 0.2.0-beta.1.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/AGENTS.md +40 -0
- package/LICENSE +174 -0
- package/README.md +89 -0
- package/package.json +51 -0
- package/roles/analyst/frame/instructions.md +88 -0
- package/roles/analyst/frame/output-template.md +52 -0
- package/roles/analyst/launch/instructions.md +63 -0
- package/roles/analyst/launch/output-template.md +50 -0
- package/roles/analyst/one-pager/instructions.md +75 -0
- package/roles/analyst/one-pager/output-template.md +38 -0
- package/roles/analyst/shape/instructions.md +63 -0
- package/roles/analyst/shape/output-template.md +52 -0
- package/roles/briefer/brief/instructions.md +77 -0
- package/roles/briefer/brief/output-template.md +37 -0
- package/roles/config.yaml +130 -0
- package/roles/evaluator/score/instructions.md +84 -0
- package/roles/evaluator/score/output-template.md +11 -0
- package/roles/initiator/init/instructions.md +111 -0
- package/roles/initiator/init/output-template-competitors.md +16 -0
- package/roles/initiator/init/output-template-ecosystem.md +19 -0
- package/roles/initiator/init/output-template-product.md +136 -0
- package/roles/prototyper/prototype/instructions.md +146 -0
- package/roles/prototyper/prototype/output-template.md +10 -0
- package/roles/reporter/readout/instructions.md +54 -0
- package/roles/reporter/readout/output-template.md +37 -0
- package/roles/scout/matchup/instructions.md +74 -0
- package/roles/scout/matchup/output-template.md +115 -0
- package/roles/skills/README.md +19 -0
- package/roles/skills/critique.md +64 -0
- package/roles/skills/head-to-head.md +88 -0
- package/roles/skills/jtbd.md +43 -0
- package/roles/skills/landscape-scan.md +77 -0
- package/roles/skills/metrics.md +58 -0
- package/roles/skills/positioning.md +44 -0
- package/roles/skills/prioritization.md +101 -0
- package/roles/skills/product-readout.md +98 -0
- package/roles/skills/tech-constraints.md +27 -0
- package/roles/skills/ux-principles.md +24 -0
- package/roles/skills/win-conditions.md +68 -0
- package/skill/SKILL.md +231 -0
- package/skill/scripts/deliberate.mjs +44 -0
- package/src/cli/deliberate.mjs +628 -0
- package/src/engine/app-boot.mjs +17 -0
- package/src/engine/briefs.mjs +101 -0
- package/src/engine/cases.mjs +17 -0
- package/src/engine/commands.mjs +75 -0
- package/src/engine/init.mjs +34 -0
- package/src/engine/layout.mjs +37 -0
- package/src/engine/log.mjs +22 -0
- package/src/engine/matchups.mjs +87 -0
- package/src/engine/onepager.mjs +51 -0
- package/src/engine/pipeline.mjs +134 -0
- package/src/engine/projects.mjs +17 -0
- package/src/engine/prompts.mjs +28 -0
- package/src/engine/prototype.mjs +86 -0
- package/src/engine/readout-charts.mjs +217 -0
- package/src/engine/readouts.mjs +132 -0
- package/src/engine/roles.mjs +137 -0
- package/src/engine/scaffold.mjs +54 -0
- package/src/engine/score.mjs +66 -0
- package/src/engine/service.mjs +18 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* prototype.mjs — the Prototype: a self-contained, openable mock of a case's primary
|
|
3
|
+
* journey, a recomputable companion to the decision record (analysis.md), NOT a funnel
|
|
4
|
+
* stage.
|
|
5
|
+
*
|
|
6
|
+
* The Prototyper is the HOST (in-session): it reads the finished case record (frame +
|
|
7
|
+
* shape + launch) and builds a self-contained artifact native to a PRIMARY surface. The
|
|
8
|
+
* artifact is always an openable single-file `index.html`, but its MEDIUM follows the
|
|
9
|
+
* surface — a clickable GUI page, a CLI terminal replay, an API request/response explorer,
|
|
10
|
+
* an agent-session replay, and so on (see roles/prototyper/prototype/instructions.md). A
|
|
11
|
+
* case can hold one prototype per primary surface: the single-surface default is the flat
|
|
12
|
+
* `prototype/index.html`; each additional surface nests as `prototype/<slug>/index.html`.
|
|
13
|
+
*
|
|
14
|
+
* The prototype is deliberately decoupled from the funnel — its own artifact — so it's
|
|
15
|
+
* built only on request (never auto-run) and can be rebuilt after the analysis is revised,
|
|
16
|
+
* exactly like the score and the one-pager. `PROTOTYPE_STAGE` is the config key
|
|
17
|
+
* (roles/config.yaml) the Prototyper role resolves under.
|
|
18
|
+
*
|
|
19
|
+
* Like the Score/One-pager, the engine here is LLM-free: it assembles the producer prompt
|
|
20
|
+
* (grounding + skills + instructions + the accumulated case record) and deterministically
|
|
21
|
+
* persists what the host produces as `deliberate/cases/<case>/prototype/[<surface>/]index.html`.
|
|
22
|
+
* The decision record links to each built surface (a "## Prototype" section).
|
|
23
|
+
*/
|
|
24
|
+
import { PROTOTYPE_STAGE } from 'sonorance/plugins/deliberate/stages.mjs';
|
|
25
|
+
import { agentConfig } from './roles.mjs';
|
|
26
|
+
import { read, loadBody, loadSkills } from './prompts.mjs';
|
|
27
|
+
import { projectContext, caseContext, cleanArtifact } from './pipeline.mjs';
|
|
28
|
+
|
|
29
|
+
// The config key (roles/config.yaml) + role folder for the Prototyper sub-job. It IS the
|
|
30
|
+
// PROTOTYPE_STAGE, but never enters the funnel state machine (STAGES) — the prototype is a
|
|
31
|
+
// standalone, recomputable artifact (like SCORE_STAGE / ONEPAGER_STAGE).
|
|
32
|
+
export const PROTO_STAGE = PROTOTYPE_STAGE;
|
|
33
|
+
|
|
34
|
+
// A prototype targets ONE of the product's PRIMARY surfaces (init marks them). The surface is a
|
|
35
|
+
// short slug — `cli`, `api`, `web`, `mcp`, … — used both to label the prompt's medium and to key
|
|
36
|
+
// the artifact on disk (`prototype/<slug>/index.html`; the default single surface stays flat).
|
|
37
|
+
// Slugified so it is a safe path segment and can never escape the case's prototype folder.
|
|
38
|
+
export const surfaceSlug = (s) => String(s || '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
39
|
+
|
|
40
|
+
// Pull the deliverable HTML out of a producer's artifact: a ```html fenced block, else a
|
|
41
|
+
// bare <!DOCTYPE …>/<html>…</html> document.
|
|
42
|
+
export const extractHtml = (t) => { const f = t.match(/```html\s*\n([\s\S]*?)```/i); if (f) return f[1].trim(); const d = t.match(/<!DOCTYPE html[\s\S]*<\/html>/i) || t.match(/<html[\s\S]*<\/html>/i); return d ? d[0].trim() : null; };
|
|
43
|
+
// A minimal placeholder page when the producer returned no usable HTML — so the case
|
|
44
|
+
// still has an openable `prototype/index.html` that names the case and invites a rebuild.
|
|
45
|
+
export const fallbackProtoHtml = (kase) => `<!DOCTYPE html><html><head><meta charset=utf-8><title>${kase.title}</title></head><body style="font:16px system-ui;max-width:640px;margin:40px auto;padding:0 20px"><h1>${kase.title}</h1><p>Prototype mock not yet generated — re-run the prototype to regenerate.</p></body></html>`;
|
|
46
|
+
|
|
47
|
+
// The exact producer prompt for a case's Prototype: grounding + the prototyping skills +
|
|
48
|
+
// the Prototyper instructions (system) and the accumulated case record — the finished
|
|
49
|
+
// frame/shape/launch whose primary journey it must mock (user). When a `surface` is given the
|
|
50
|
+
// prompt pins the medium to that primary surface (a GUI clickable page, a CLI terminal replay,
|
|
51
|
+
// an API request/response explorer, an agent-session replay, …); otherwise the Prototyper picks
|
|
52
|
+
// the product's single primary surface. The in-harness skill hands this to the host, who builds
|
|
53
|
+
// the self-contained HTML artifact native to that surface.
|
|
54
|
+
export async function prototypePrompt(store, project, kase, surface = '') {
|
|
55
|
+
const slug = surfaceSlug(surface);
|
|
56
|
+
const cfg = agentConfig(PROTO_STAGE);
|
|
57
|
+
const instruction = await loadBody(cfg.instructions);
|
|
58
|
+
const agents = await read('AGENTS.md');
|
|
59
|
+
const template = await read(cfg.templates.default);
|
|
60
|
+
const skillsBlock = (await loadSkills(cfg.skills)) || '(none)';
|
|
61
|
+
const pctx = projectContext(store, project);
|
|
62
|
+
const system = `${agents}\n\n${pctx}\n\n## Skills\n${skillsBlock}\n\n## Stage instructions\n${instruction}`;
|
|
63
|
+
const ctx = caseContext(store, kase);
|
|
64
|
+
const tpl = template
|
|
65
|
+
? `\n\n----- OUTPUT TEMPLATE -----\n(Fill EVERY section with real, grounded content drawn ONLY from the record below. The italic _..._ lines and parentheticals are guidance for you — replace them; never echo the template's guidance text.)\n${template}`
|
|
66
|
+
: '';
|
|
67
|
+
const surfaceLine = slug
|
|
68
|
+
? `Target surface: **${slug}** — build the prototype in the medium native to THIS primary surface (see the Prototyper instructions), not a graphical app unless this surface IS a GUI.\n`
|
|
69
|
+
: `Target the product's single primary surface — build the prototype in the medium native to it (see the Prototyper instructions).\n`;
|
|
70
|
+
const user = `${surfaceLine}Finished case record (mock ONLY its primary journey — invent no new capabilities or demand):\n${ctx}\nProduce the prototype as a single self-contained HTML document.${tpl}`;
|
|
71
|
+
return { system, user, template, model: cfg.model, surface: slug };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Persist a produced Prototype (LLM-free): clean the artifact, pull out the deliverable
|
|
75
|
+
// HTML (or a minimal fallback page), and write it to `prototype/[<surface>/]index.html` beside
|
|
76
|
+
// the record (the store also refreshes the record's links). The HTML is byte-preserved — never
|
|
77
|
+
// reflowed. Shared by the engine and `deliberate case prototype save`.
|
|
78
|
+
export async function persistPrototype(store, project, kase, rawArtifact, surface = '') {
|
|
79
|
+
const slug = surfaceSlug(surface);
|
|
80
|
+
const cfg = agentConfig(PROTO_STAGE);
|
|
81
|
+
const template = await read(cfg.templates.default);
|
|
82
|
+
const art = cleanArtifact(rawArtifact, template);
|
|
83
|
+
const html = extractHtml(art) || fallbackProtoHtml(kase);
|
|
84
|
+
store.writePrototype(kase.id, html, slug);
|
|
85
|
+
return { case: store.getCase(kase.id), file: store.prototypeRef(kase.id, slug), surface: slug };
|
|
86
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, extname, join } from 'node:path';
|
|
3
|
+
import { compile } from 'vega-lite';
|
|
4
|
+
import { parse, View } from 'vega';
|
|
5
|
+
|
|
6
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
7
|
+
const SAFE_FILE = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?\.svg$/;
|
|
8
|
+
const MAX_CHARTS = 3;
|
|
9
|
+
const MAX_POINTS = 60;
|
|
10
|
+
const MAX_SVG_BYTES = 1_000_000;
|
|
11
|
+
|
|
12
|
+
const text = (value, field, max) => {
|
|
13
|
+
const out = String(value || '').trim();
|
|
14
|
+
if (!out) throw new Error(`chart ${field} is required`);
|
|
15
|
+
if (out.length > max) throw new Error(`chart ${field} must be at most ${max} characters`);
|
|
16
|
+
return out;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const dateValue = (value, field) => {
|
|
20
|
+
const out = String(value || '');
|
|
21
|
+
if (!ISO_DATE.test(out) || Number.isNaN(Date.parse(`${out}T00:00:00Z`))) {
|
|
22
|
+
throw new Error(`chart ${field} must be an ISO date (YYYY-MM-DD)`);
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function normalizeTrendChartSpec(input) {
|
|
28
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new Error('chart spec must be an object');
|
|
29
|
+
const title = text(input.title, 'title', 100);
|
|
30
|
+
const unit = text(input.unit, 'unit', 40);
|
|
31
|
+
const comparison = text(input.comparison, 'comparison', 40);
|
|
32
|
+
const source = text(input.source, 'source', 2048);
|
|
33
|
+
if (!Array.isArray(input.series) || input.series.length < 4) throw new Error('chart series requires at least 4 completed periods');
|
|
34
|
+
if (input.series.length > MAX_POINTS) throw new Error(`chart series supports at most ${MAX_POINTS} periods`);
|
|
35
|
+
|
|
36
|
+
let previousEnd = '';
|
|
37
|
+
const seen = new Set();
|
|
38
|
+
const series = input.series.map((point, index) => {
|
|
39
|
+
if (!point || typeof point !== 'object' || Array.isArray(point)) throw new Error(`chart series[${index}] must be an object`);
|
|
40
|
+
const period_start = dateValue(point.period_start, `series[${index}].period_start`);
|
|
41
|
+
const period_end = dateValue(point.period_end, `series[${index}].period_end`);
|
|
42
|
+
if (period_start > period_end) throw new Error(`chart series[${index}] starts after it ends`);
|
|
43
|
+
if (previousEnd && period_end <= previousEnd) throw new Error('chart series must be strictly ordered by period_end');
|
|
44
|
+
if (seen.has(period_end)) throw new Error(`chart series repeats period_end ${period_end}`);
|
|
45
|
+
const value = point.value === null ? null : Number(point.value);
|
|
46
|
+
if (value !== null && !Number.isFinite(value)) throw new Error(`chart series[${index}].value must be finite or null`);
|
|
47
|
+
previousEnd = period_end;
|
|
48
|
+
seen.add(period_end);
|
|
49
|
+
return { period_start, period_end, value };
|
|
50
|
+
});
|
|
51
|
+
if (series.filter(point => point.value !== null).length < 4) {
|
|
52
|
+
throw new Error('chart series requires at least 4 completed periods with finite values');
|
|
53
|
+
}
|
|
54
|
+
return { title, unit, comparison, source, series };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const xmlAttr = (value) => String(value).replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<').replaceAll('>', '>');
|
|
58
|
+
const xmlText = (value) => String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
|
59
|
+
|
|
60
|
+
const hasUnsafeSvgMarkup = (svg) => {
|
|
61
|
+
if (/<script\b|<foreignObject\b/i.test(svg)) return true;
|
|
62
|
+
for (const match of svg.matchAll(/<[A-Za-z][^>]*>/g)) {
|
|
63
|
+
const tag = match[0];
|
|
64
|
+
const attributes = /\s([A-Za-z_:][A-Za-z0-9:._-]*)\s*(?:=\s*("[^"]*"|'[^']*'|[^\s>]+))?/g;
|
|
65
|
+
for (const attribute of tag.matchAll(attributes)) {
|
|
66
|
+
const name = attribute[1].toLowerCase();
|
|
67
|
+
const value = String(attribute[2] || '').replace(/^(['"])([\s\S]*)\1$/, '$2');
|
|
68
|
+
if (/^on[a-z]+$/.test(name)) return true;
|
|
69
|
+
if ((name === 'href' || name.endsWith(':href') || name === 'src') && /^(?:https?:|data:)/i.test(value)) return true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export async function renderTrendChart(input) {
|
|
76
|
+
const chart = normalizeTrendChartSpec(input);
|
|
77
|
+
const first = chart.series[0];
|
|
78
|
+
const last = chart.series.at(-1);
|
|
79
|
+
const subtitle = `${chart.comparison} · ${first.period_start} – ${last.period_end} · ${chart.unit}`;
|
|
80
|
+
const values = chart.series.map(point => ({
|
|
81
|
+
period_end: `${point.period_end}T00:00:00Z`,
|
|
82
|
+
period: point.period_start === point.period_end ? point.period_end : `${point.period_start} – ${point.period_end}`,
|
|
83
|
+
value: point.value,
|
|
84
|
+
}));
|
|
85
|
+
const spec = {
|
|
86
|
+
$schema: 'https://vega.github.io/schema/vega-lite/v6.json',
|
|
87
|
+
width: 720,
|
|
88
|
+
height: 260,
|
|
89
|
+
background: '#ffffff',
|
|
90
|
+
padding: { left: 12, right: 20, top: 12, bottom: 8 },
|
|
91
|
+
title: {
|
|
92
|
+
text: chart.title,
|
|
93
|
+
subtitle,
|
|
94
|
+
anchor: 'start',
|
|
95
|
+
color: '#17212b',
|
|
96
|
+
font: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
|
97
|
+
fontSize: 18,
|
|
98
|
+
fontWeight: 650,
|
|
99
|
+
subtitleColor: '#52606d',
|
|
100
|
+
subtitleFont: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
|
|
101
|
+
subtitleFontSize: 12,
|
|
102
|
+
subtitlePadding: 6,
|
|
103
|
+
offset: 16,
|
|
104
|
+
},
|
|
105
|
+
data: { values },
|
|
106
|
+
mark: {
|
|
107
|
+
type: 'line',
|
|
108
|
+
color: '#2f8299',
|
|
109
|
+
strokeWidth: 3,
|
|
110
|
+
invalid: 'break-paths-show-path-domains',
|
|
111
|
+
point: { filled: true, fill: '#ffffff', stroke: '#2f8299', strokeWidth: 2, size: 58 },
|
|
112
|
+
},
|
|
113
|
+
encoding: {
|
|
114
|
+
x: {
|
|
115
|
+
field: 'period_end',
|
|
116
|
+
type: 'temporal',
|
|
117
|
+
axis: {
|
|
118
|
+
title: null,
|
|
119
|
+
format: '%b %d',
|
|
120
|
+
grid: false,
|
|
121
|
+
labelColor: '#52606d',
|
|
122
|
+
labelFontSize: 11,
|
|
123
|
+
labelOverlap: 'greedy',
|
|
124
|
+
tickCount: 6,
|
|
125
|
+
tickColor: '#9fb3c8',
|
|
126
|
+
domainColor: '#9fb3c8',
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
y: {
|
|
130
|
+
field: 'value',
|
|
131
|
+
type: 'quantitative',
|
|
132
|
+
scale: { zero: true, nice: true },
|
|
133
|
+
axis: {
|
|
134
|
+
title: chart.unit,
|
|
135
|
+
titleColor: '#334e68',
|
|
136
|
+
titleFontSize: 12,
|
|
137
|
+
titleFontWeight: 600,
|
|
138
|
+
titlePadding: 12,
|
|
139
|
+
labelColor: '#52606d',
|
|
140
|
+
labelFontSize: 11,
|
|
141
|
+
gridColor: '#d9e2ec',
|
|
142
|
+
gridOpacity: 0.8,
|
|
143
|
+
tickColor: '#9fb3c8',
|
|
144
|
+
domain: false,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
tooltip: [
|
|
148
|
+
{ field: 'period', type: 'nominal', title: 'Period' },
|
|
149
|
+
{ field: 'value', type: 'quantitative', title: chart.unit },
|
|
150
|
+
],
|
|
151
|
+
},
|
|
152
|
+
config: {
|
|
153
|
+
view: { stroke: null },
|
|
154
|
+
axis: { labelFont: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', titleFont: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' },
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
const runtime = parse(compile(spec).spec);
|
|
158
|
+
const view = new View(runtime, { renderer: 'none' });
|
|
159
|
+
try {
|
|
160
|
+
let svg = await view.toSVG();
|
|
161
|
+
const aria = `${chart.title}. ${subtitle}. Source: ${chart.source}.`;
|
|
162
|
+
svg = svg.replace('<svg ', `<svg data-deliberate-chart="1" role="img" aria-label="${xmlAttr(aria)}" `);
|
|
163
|
+
svg = svg.replace(/(<svg[^>]*>)/, `$1<title>${xmlText(chart.title)}</title><desc>${xmlText(aria)}</desc>`);
|
|
164
|
+
return svg;
|
|
165
|
+
} finally {
|
|
166
|
+
view.finalize();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export async function renderTrendChartFile(specFile, outputFile) {
|
|
171
|
+
if (extname(outputFile).toLowerCase() !== '.svg') throw new Error('chart output must end in .svg');
|
|
172
|
+
let input;
|
|
173
|
+
try { input = JSON.parse(readFileSync(specFile, 'utf8')); }
|
|
174
|
+
catch (error) { throw new Error(`could not read chart spec: ${error.message}`); }
|
|
175
|
+
const svg = await renderTrendChart(input);
|
|
176
|
+
mkdirSync(dirname(outputFile), { recursive: true });
|
|
177
|
+
writeFileSync(outputFile, svg.endsWith('\n') ? svg : `${svg}\n`);
|
|
178
|
+
return outputFile;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function loadReadoutCharts(bundleDir, markdown) {
|
|
182
|
+
const chartsDir = join(bundleDir, 'charts');
|
|
183
|
+
const refs = [];
|
|
184
|
+
const refPattern = /!\[([^\]]+)\]\((?:\.\/)?charts\/([a-zA-Z0-9][a-zA-Z0-9._-]*\.svg)(?:\s+"[^"]*")?\)/g;
|
|
185
|
+
for (const match of String(markdown || '').matchAll(refPattern)) {
|
|
186
|
+
if (!match[1].trim()) throw new Error('readout chart images require descriptive alt text');
|
|
187
|
+
refs.push(match[2]);
|
|
188
|
+
}
|
|
189
|
+
if (!existsSync(chartsDir)) {
|
|
190
|
+
if (refs.length) throw new Error('readout references charts, but the bundle has no charts/ directory');
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
const entries = readdirSync(chartsDir, { withFileTypes: true });
|
|
194
|
+
if (entries.some(entry => !entry.isFile())) throw new Error('readout charts/ may contain SVG files only');
|
|
195
|
+
const names = entries.map(entry => entry.name).sort();
|
|
196
|
+
if (names.length > MAX_CHARTS) throw new Error(`readout bundles support at most ${MAX_CHARTS} charts`);
|
|
197
|
+
if (names.some(name => extname(name).toLowerCase() !== '.svg' || !SAFE_FILE.test(name))) {
|
|
198
|
+
throw new Error('readout chart filenames must be lowercase, safe, and end in .svg');
|
|
199
|
+
}
|
|
200
|
+
const uniqueRefs = [...new Set(refs)];
|
|
201
|
+
const missing = uniqueRefs.filter(name => !names.includes(name));
|
|
202
|
+
if (missing.length) throw new Error(`readout references missing chart: ${missing.join(', ')}`);
|
|
203
|
+
const unused = names.filter(name => !uniqueRefs.includes(name));
|
|
204
|
+
if (unused.length) throw new Error(`readout bundle contains unreferenced chart: ${unused.join(', ')}`);
|
|
205
|
+
|
|
206
|
+
return names.map(name => {
|
|
207
|
+
const file = join(chartsDir, name);
|
|
208
|
+
const size = statSync(file).size;
|
|
209
|
+
if (!size || size > MAX_SVG_BYTES) throw new Error(`readout chart ${name} must be between 1 byte and ${MAX_SVG_BYTES} bytes`);
|
|
210
|
+
const content = readFileSync(file);
|
|
211
|
+
const svg = content.toString('utf8');
|
|
212
|
+
if (!svg.includes('data-deliberate-chart="1"') || hasUnsafeSvgMarkup(svg)) {
|
|
213
|
+
throw new Error(`readout chart ${name} is not a safe Deliberate-generated SVG`);
|
|
214
|
+
}
|
|
215
|
+
return { name: basename(name), content };
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* readouts.mjs — the Reporter: a project-scoped product readout over configured
|
|
3
|
+
* metrics, customer evidence, and product context. The host performs the source
|
|
4
|
+
* research in-session; this LLM-free engine computes the reporting period,
|
|
5
|
+
* assembles the grounded prompt, and persists the produced Markdown artifact.
|
|
6
|
+
*/
|
|
7
|
+
import { agentConfig } from './roles.mjs';
|
|
8
|
+
import { read, loadBody, loadSkills } from './prompts.mjs';
|
|
9
|
+
import { projectContext, cleanArtifact } from './pipeline.mjs';
|
|
10
|
+
import { unwrapProse } from 'sonorance/plugins/deliberate/markdown.mjs';
|
|
11
|
+
|
|
12
|
+
export const READOUT_STAGE = 'readout';
|
|
13
|
+
const DAY = 86400000;
|
|
14
|
+
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
|
15
|
+
|
|
16
|
+
const calendarDayAt = (ts, timeZone) => {
|
|
17
|
+
let parts;
|
|
18
|
+
try {
|
|
19
|
+
parts = new Intl.DateTimeFormat('en-US', {
|
|
20
|
+
timeZone,
|
|
21
|
+
year: 'numeric',
|
|
22
|
+
month: '2-digit',
|
|
23
|
+
day: '2-digit',
|
|
24
|
+
}).formatToParts(new Date(ts));
|
|
25
|
+
} catch (error) {
|
|
26
|
+
if (error instanceof RangeError) throw new Error('--timezone must be a valid IANA timezone');
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
const values = Object.fromEntries(parts.map(({ type, value }) => [type, value]));
|
|
30
|
+
return Date.UTC(Number(values.year), Number(values.month) - 1, Number(values.day));
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const parseDate = (value, label) => {
|
|
34
|
+
if (!DATE_PATTERN.test(String(value || ''))) throw new Error(`${label} must be YYYY-MM-DD`);
|
|
35
|
+
const [year, month, day] = value.split('-').map(Number);
|
|
36
|
+
const timestamp = Date.UTC(year, month - 1, day);
|
|
37
|
+
const parsed = new Date(timestamp);
|
|
38
|
+
if (parsed.getUTCFullYear() !== year || parsed.getUTCMonth() !== month - 1 || parsed.getUTCDate() !== day) {
|
|
39
|
+
throw new Error(`${label} must be a valid calendar date`);
|
|
40
|
+
}
|
|
41
|
+
return timestamp;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function comparisonPeriod(start, end) {
|
|
45
|
+
const endExclusive = end + 1;
|
|
46
|
+
const startDate = new Date(start);
|
|
47
|
+
const endDate = new Date(endExclusive);
|
|
48
|
+
const wholeCalendarMonths = startDate.getUTCDate() === 1 && endDate.getUTCDate() === 1
|
|
49
|
+
? (endDate.getUTCFullYear() * 12 + endDate.getUTCMonth()) - (startDate.getUTCFullYear() * 12 + startDate.getUTCMonth())
|
|
50
|
+
: 0;
|
|
51
|
+
if (wholeCalendarMonths > 0) {
|
|
52
|
+
const comparisonStart = Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth() - wholeCalendarMonths, 1);
|
|
53
|
+
return { start: comparisonStart, end: start - 1 };
|
|
54
|
+
}
|
|
55
|
+
const duration = endExclusive - start;
|
|
56
|
+
return { start: start - duration, end: start - 1 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function readoutPeriod(store, pid, at = Date.now(), { periodStart, periodEnd, timeZone = 'UTC' } = {}) {
|
|
60
|
+
if (!!periodStart !== !!periodEnd) throw new Error('--period-start and --period-end must be provided together');
|
|
61
|
+
const zone = timeZone || 'UTC';
|
|
62
|
+
const today = calendarDayAt(at, zone);
|
|
63
|
+
let start;
|
|
64
|
+
let end;
|
|
65
|
+
const custom = !!periodStart;
|
|
66
|
+
if (custom) {
|
|
67
|
+
start = parseDate(periodStart, '--period-start');
|
|
68
|
+
end = parseDate(periodEnd, '--period-end') + DAY - 1;
|
|
69
|
+
if (start > end) throw new Error('--period-start must be on or before --period-end');
|
|
70
|
+
if (end >= today) throw new Error('the readout period must be fully completed; --period-end must be before today');
|
|
71
|
+
} else {
|
|
72
|
+
const weekday = new Date(today).getUTCDay();
|
|
73
|
+
const currentWeekStart = today - ((weekday + 6) % 7) * DAY;
|
|
74
|
+
start = currentWeekStart - 7 * DAY;
|
|
75
|
+
end = currentWeekStart - 1;
|
|
76
|
+
}
|
|
77
|
+
const last = store.lastReadoutEnd(pid);
|
|
78
|
+
const firstEver = last == null;
|
|
79
|
+
const comparison = comparisonPeriod(start, end);
|
|
80
|
+
return { start, end, comparisonStart: comparison.start, comparisonEnd: comparison.end, firstEver, last, custom, timeZone: zone };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const fmtDate = (ts) => new Date(ts).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' });
|
|
84
|
+
export const readoutPeriodLabel = (start, end) => `${fmtDate(start)} – ${fmtDate(end)}`;
|
|
85
|
+
const stripFrontmatter = (raw) => { const m = String(raw || '').match(/^---\n[\s\S]*?\n---\n?/); return m ? raw.slice(m[0].length) : String(raw || ''); };
|
|
86
|
+
|
|
87
|
+
export function previousReadoutBlock(store, pid) {
|
|
88
|
+
const prev = (store.listReadouts(pid) || [])[0];
|
|
89
|
+
if (!prev) return '';
|
|
90
|
+
const rec = store.readReadoutRecord(prev.id);
|
|
91
|
+
const body = stripFrontmatter(rec?.text).trim();
|
|
92
|
+
if (!body) return '';
|
|
93
|
+
return `## Previous readout — read-only prior context (${readoutPeriodLabel(prev.period_start, prev.period_end)})\nUse this to identify what materially changed, avoid repeating unchanged commentary, and preserve metric-definition continuity. Do not treat prior hypotheses as established facts.\n\n${body}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function windowNote(win) {
|
|
97
|
+
const selection = win.custom ? 'the user-requested completed period' : 'the default previous completed calendar week';
|
|
98
|
+
if (win.firstEver) return `This is the FIRST readout for this project. Analyze ${selection}.`;
|
|
99
|
+
return `This is NOT the first readout. Analyze ${selection}; the previous readout is narrative context only, not the comparison period.`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function readoutPrompt(store, project, { at = Date.now(), periodStart, periodEnd, timeZone } = {}) {
|
|
103
|
+
const cfg = agentConfig(READOUT_STAGE);
|
|
104
|
+
const instruction = await loadBody(cfg.instructions);
|
|
105
|
+
const agents = await read('AGENTS.md');
|
|
106
|
+
const template = await read(cfg.templates.default);
|
|
107
|
+
const skillsBlock = (await loadSkills(cfg.skills)) || '(none)';
|
|
108
|
+
const system = `${agents}\n\n${projectContext(store, project)}\n\n## Skills\n${skillsBlock}\n\n## Stage instructions\n${instruction}`;
|
|
109
|
+
const win = readoutPeriod(store, project.id, at, { periodStart, periodEnd, timeZone });
|
|
110
|
+
const windowBlock = `## Reporting period (STRICT)\n${windowNote(win)}\n- timezone: ${win.timeZone}\n- period_start: ${fmtDate(win.start)}\n- period_end: ${fmtDate(win.end)}\n- comparison_start: ${fmtDate(win.comparisonStart)}\n- comparison_end: ${fmtDate(win.comparisonEnd)}\nSet the readout's "Period:" line to exactly "${readoutPeriodLabel(win.start, win.end)}". This completed period grounds the entire report: metrics, customer evidence, releases, experiments, incidents, takeaways, insights, and actions. Use only evidence inside it. Compare metrics with the immediately preceding equivalent completed period shown above. Never mix in data from the period currently in progress.`;
|
|
111
|
+
const previous = previousReadoutBlock(store, project.id);
|
|
112
|
+
const tpl = `\n\n----- OUTPUT TEMPLATE -----\n(Fill every section with real, sourced content. Replace all italic guidance; never echo placeholders and do not add methodology, assumptions, notes, or a source appendix.)\n${template}`;
|
|
113
|
+
const user = `${windowBlock}${previous ? `\n\n${previous}` : ''}\n\nProduce the product readout.${tpl}`;
|
|
114
|
+
return { system, user, template, window: win, model: cfg.model };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function persistReadout(store, project, rawArtifact, { at = Date.now(), charts = [], periodStart, periodEnd, timeZone } = {}) {
|
|
118
|
+
const cfg = agentConfig(READOUT_STAGE);
|
|
119
|
+
const template = await read(cfg.templates.default);
|
|
120
|
+
const body = unwrapProse(cleanArtifact(rawArtifact, template));
|
|
121
|
+
const win = readoutPeriod(store, project.id, at, { periodStart, periodEnd, timeZone });
|
|
122
|
+
const expectedPeriod = readoutPeriodLabel(win.start, win.end);
|
|
123
|
+
const firstBodyLine = body.split(/\r?\n/).slice(1).find(line => line.trim())?.trim() || '';
|
|
124
|
+
const artifactPeriod = firstBodyLine.match(/^Period:\s*(.+?)\s*$/i)?.[1]?.trim();
|
|
125
|
+
if (artifactPeriod !== expectedPeriod) {
|
|
126
|
+
throw new Error(`readout Period must be exactly "${expectedPeriod}" for the selected completed period`);
|
|
127
|
+
}
|
|
128
|
+
const readout = store.createReadout(project.id, {
|
|
129
|
+
period_start: win.start, period_end: win.end, model: cfg.model, body, charts,
|
|
130
|
+
}, at);
|
|
131
|
+
return { readout, window: win };
|
|
132
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* roles.mjs — per-stage configuration, driven by roles/config.yaml.
|
|
3
|
+
*
|
|
4
|
+
* Each pipeline stage maps to an instructions path, an output template, a list of
|
|
5
|
+
* reusable-skill paths, and — for `score` only — a model. The YAML is re-read on every
|
|
6
|
+
* lookup, so editing it takes effect on the next invocation with no restart.
|
|
7
|
+
*
|
|
8
|
+
* Model resolution: DELIBERATE_MODEL=stub short-circuits to the stub model (so offline
|
|
9
|
+
* tests stay deterministic) → roles/config.yaml → the built-in host MODEL. The host-run
|
|
10
|
+
* stages (frame/shape/prototype) omit a model and inherit the default; only `score` sets
|
|
11
|
+
* an explicit (cross-vendor) model for the isolated Evaluator sub-agent the host spawns.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
|
|
17
|
+
// The primary default model (used for the stub short-circuit in tests, and as the
|
|
18
|
+
// fallback when a stage declares no explicit model in roles/config.yaml).
|
|
19
|
+
const MODEL = process.env.DELIBERATE_MODEL || process.env.MODEL || 'claude-opus-4.8';
|
|
20
|
+
|
|
21
|
+
const CONFIG = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'roles', 'config.yaml');
|
|
22
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
23
|
+
const KNOWN_STAGES = new Set(['frame', 'score', 'shape', 'launch', 'prototype', 'one-pager', 'brief', 'readout', 'matchup', 'init']);
|
|
24
|
+
const EFFORTS = new Set(['none', 'low', 'medium', 'high', 'xhigh', 'max']);
|
|
25
|
+
const CONTEXTS = new Set(['default', 'long_context']);
|
|
26
|
+
|
|
27
|
+
const unquote = (s) => {
|
|
28
|
+
const value = s.trim();
|
|
29
|
+
const first = value[0], last = value[value.length - 1];
|
|
30
|
+
const startsQuoted = first === '"' || first === "'";
|
|
31
|
+
const endsQuoted = last === '"' || last === "'";
|
|
32
|
+
if (startsQuoted || endsQuoted) {
|
|
33
|
+
if (!startsQuoted || first !== last || value.length < 2) throw new Error('unbalanced quoted value');
|
|
34
|
+
return value.slice(1, -1).trim();
|
|
35
|
+
}
|
|
36
|
+
return value;
|
|
37
|
+
};
|
|
38
|
+
const parseList = (v) => v.replace(/^\[|\]$/g, '').split(',').map(unquote).filter(Boolean);
|
|
39
|
+
|
|
40
|
+
// Minimal indentation-based YAML reader (no runtime deps). Handles nested maps
|
|
41
|
+
// (agent → fields → templates) and inline flow lists (`[a, b]`). Strips inline
|
|
42
|
+
// `# comments` and blank lines; values never contain spaces or '#'.
|
|
43
|
+
function readConfig(configPath) {
|
|
44
|
+
const root = {};
|
|
45
|
+
const stack = [{ indent: -1, node: root }];
|
|
46
|
+
let source;
|
|
47
|
+
try {
|
|
48
|
+
source = readFileSync(configPath, 'utf8');
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new Error(`Could not read roles/config.yaml: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
for (const [index, raw] of source.split('\n').entries()) {
|
|
53
|
+
try {
|
|
54
|
+
if (!raw.trim() || raw.trimStart().startsWith('#')) continue;
|
|
55
|
+
const line = raw.replace(/\s+#.*$/, '').replace(/\s+$/, '');
|
|
56
|
+
if (!line.trim()) continue;
|
|
57
|
+
const indent = raw.match(/^ */)[0].length;
|
|
58
|
+
const m = line.trim().match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
|
|
59
|
+
if (!m) throw new Error('expected key: value');
|
|
60
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
|
|
61
|
+
const parent = stack[stack.length - 1].node;
|
|
62
|
+
const [, key, value] = m;
|
|
63
|
+
if (Object.hasOwn(parent, key)) throw new Error(`duplicate key "${key}"`);
|
|
64
|
+
if (value.startsWith('[') !== value.endsWith(']')) throw new Error(`unterminated list for "${key}"`);
|
|
65
|
+
if (value === '') { const child = {}; parent[key] = child; stack.push({ indent, node: child }); }
|
|
66
|
+
else parent[key] = value.startsWith('[') ? parseList(value) : unquote(value);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new Error(`Invalid roles/config.yaml at line ${index + 1}: ${error.message}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return root;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateStage(stage, cfg) {
|
|
75
|
+
if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) throw new Error(`Invalid roles/config.yaml: missing "${stage}" stage`);
|
|
76
|
+
const allowed = new Set(['instructions', 'templates', 'skills', ...(stage === 'score' ? ['model', 'reasoning_effort', 'context'] : [])]);
|
|
77
|
+
for (const key of Object.keys(cfg)) {
|
|
78
|
+
if (!allowed.has(key)) throw new Error(`Invalid roles/config.yaml: unexpected "${stage}.${key}"`);
|
|
79
|
+
}
|
|
80
|
+
if (typeof cfg.instructions !== 'string' || !cfg.instructions) throw new Error(`Invalid roles/config.yaml: "${stage}.instructions" is required`);
|
|
81
|
+
if (!cfg.templates || typeof cfg.templates !== 'object' || Array.isArray(cfg.templates) || !Object.keys(cfg.templates).length) {
|
|
82
|
+
throw new Error(`Invalid roles/config.yaml: "${stage}.templates" must declare at least one output`);
|
|
83
|
+
}
|
|
84
|
+
const requiredTemplates = stage === 'init' ? ['product', 'competitors', 'ecosystem'] : ['default'];
|
|
85
|
+
for (const name of Object.keys(cfg.templates)) {
|
|
86
|
+
if (!requiredTemplates.includes(name)) throw new Error(`Invalid roles/config.yaml: unexpected "${stage}.templates.${name}"`);
|
|
87
|
+
}
|
|
88
|
+
for (const name of requiredTemplates) {
|
|
89
|
+
if (typeof cfg.templates[name] !== 'string' || !cfg.templates[name]) {
|
|
90
|
+
throw new Error(`Invalid roles/config.yaml: "${stage}.templates.${name}" is required`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (stage === 'score' && (typeof cfg.model !== 'string' || !cfg.model)) {
|
|
94
|
+
throw new Error('Invalid roles/config.yaml: "score.model" is required');
|
|
95
|
+
}
|
|
96
|
+
if (cfg.reasoning_effort !== undefined && !EFFORTS.has(cfg.reasoning_effort)) {
|
|
97
|
+
throw new Error('Invalid roles/config.yaml: "score.reasoning_effort" is invalid');
|
|
98
|
+
}
|
|
99
|
+
if (cfg.context !== undefined && !CONTEXTS.has(cfg.context)) {
|
|
100
|
+
throw new Error('Invalid roles/config.yaml: "score.context" is invalid');
|
|
101
|
+
}
|
|
102
|
+
if (!Array.isArray(cfg.skills)) throw new Error(`Invalid roles/config.yaml: "${stage}.skills" must be a list`);
|
|
103
|
+
const paths = [
|
|
104
|
+
[`${stage}.instructions`, cfg.instructions],
|
|
105
|
+
...Object.entries(cfg.templates).map(([name, path]) => [`${stage}.templates.${name}`, path]),
|
|
106
|
+
...cfg.skills.map((path, index) => [`${stage}.skills[${index}]`, path]),
|
|
107
|
+
];
|
|
108
|
+
for (const [name, path] of paths) {
|
|
109
|
+
if (typeof path !== 'string' || !path || !existsSync(join(ROOT, path))) {
|
|
110
|
+
throw new Error(`Invalid roles/config.yaml: "${name}" must reference an existing file`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return cfg;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Full resolved config for a pipeline stage: { model, instructions, templates, skills }.
|
|
117
|
+
export function agentConfig(stage, configPath = CONFIG) {
|
|
118
|
+
if (!KNOWN_STAGES.has(stage)) throw new Error(`Unknown role stage: ${stage}`);
|
|
119
|
+
const all = readConfig(configPath);
|
|
120
|
+
for (const known of KNOWN_STAGES) validateStage(known, all[known]);
|
|
121
|
+
const cfg = all[stage];
|
|
122
|
+
const stub = (process.env.DELIBERATE_MODEL || '') === 'stub';
|
|
123
|
+
// Templates are config-driven. Most stages declare a single `default` output template
|
|
124
|
+
// (the section's shape); `init` declares three (`product` + `competitors` + `ecosystem`) and no `default`.
|
|
125
|
+
return {
|
|
126
|
+
model: stub ? MODEL : (cfg.model || MODEL),
|
|
127
|
+
instructions: cfg.instructions,
|
|
128
|
+
templates: { ...cfg.templates },
|
|
129
|
+
skills: [...cfg.skills],
|
|
130
|
+
// Optional Copilot CLI tuning knobs (null = don't pass the flag → CLI default).
|
|
131
|
+
effort: cfg.reasoning_effort || null,
|
|
132
|
+
context: cfg.context || null,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const modelFor = (agent) => agentConfig(agent).model;
|
|
137
|
+
export const skillsFor = (agent) => agentConfig(agent).skills;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scaffold.mjs — write a Deliberate project's starter context files (product.md, competitors.md, ecosystem.md).
|
|
3
|
+
*
|
|
4
|
+
* This is agent-init IP: the scaffolds are the Initiator role's **output templates** — declared in
|
|
5
|
+
* `roles/config.yaml` (init.templates.product / .competitors / .ecosystem) and read at project creation with
|
|
6
|
+
* `{{name}}` substituted. It lives in the SKILL (not the app's bundled record store) because it is
|
|
7
|
+
* part of `deliberate init`: the Sonorance host only registers the vault; the Deliberate skill lays
|
|
8
|
+
* down its visible starter content. Idempotent — never overwrites a file that already exists.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs';
|
|
11
|
+
import { dirname, join } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { agentConfig } from './roles.mjs';
|
|
14
|
+
import { contextFile, competitorsFile, ecosystemFile } from 'sonorance/plugins/deliberate/paths.mjs';
|
|
15
|
+
|
|
16
|
+
// Skill-repo root (this file is at <skill>/src/engine/scaffold.mjs) → resolve role template paths.
|
|
17
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
18
|
+
const repoFile = (rel) => join(REPO_ROOT, rel);
|
|
19
|
+
|
|
20
|
+
const contextScaffold = (kind, subs = {}) => {
|
|
21
|
+
const rel = agentConfig('init').templates[kind]; // path from roles/config.yaml
|
|
22
|
+
const text = readFileSync(repoFile(rel), 'utf8');
|
|
23
|
+
return text.replace(/\{\{(\w+)\}\}/g, (_m, k) => subs[k] ?? '');
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const writeIfAbsent = (p, content) => { if (!existsSync(p)) { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, content); } };
|
|
27
|
+
|
|
28
|
+
const CONTEXT_POINTER = '## Product context for agents\n\nDetailed product context for agentic workflows is maintained in [`deliberate/context/`](deliberate/context/). Agents should read those files before product, strategy, market, positioning, or implementation work so decisions stay grounded in the project’s goals, users, competitors, and ecosystem.\n';
|
|
29
|
+
|
|
30
|
+
export function ensureContextPointer(project) {
|
|
31
|
+
const candidates = readdirSync(project.dir)
|
|
32
|
+
.filter(name => /^readme(?:\.md|\.markdown)?$/i.test(name))
|
|
33
|
+
.sort((a, b) => (a === 'README.md' ? -1 : b === 'README.md' ? 1 : a.localeCompare(b)));
|
|
34
|
+
const readme = join(project.dir, candidates[0] || 'README.md');
|
|
35
|
+
if (!existsSync(readme)) {
|
|
36
|
+
writeFileSync(readme, `# ${project.name}\n\n${CONTEXT_POINTER}`);
|
|
37
|
+
return readme;
|
|
38
|
+
}
|
|
39
|
+
const current = readFileSync(readme, 'utf8');
|
|
40
|
+
if (/deliberate\/context\/?/.test(current)) return readme;
|
|
41
|
+
const separator = current.endsWith('\n\n') ? '' : current.endsWith('\n') ? '\n' : '\n\n';
|
|
42
|
+
writeFileSync(readme, `${current}${separator}${CONTEXT_POINTER}`);
|
|
43
|
+
return readme;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Lay down the starter context for a freshly created / opened Deliberate vault.
|
|
47
|
+
export function scaffoldContext(project) {
|
|
48
|
+
if (!project || !project.dir) return project;
|
|
49
|
+
writeIfAbsent(contextFile(project.dir), contextScaffold('product', { name: project.name }));
|
|
50
|
+
writeIfAbsent(competitorsFile(project.dir), contextScaffold('competitors'));
|
|
51
|
+
writeIfAbsent(ecosystemFile(project.dir), contextScaffold('ecosystem'));
|
|
52
|
+
ensureContextPointer(project);
|
|
53
|
+
return project;
|
|
54
|
+
}
|