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.
Files changed (61) hide show
  1. package/AGENTS.md +40 -0
  2. package/LICENSE +174 -0
  3. package/README.md +89 -0
  4. package/package.json +51 -0
  5. package/roles/analyst/frame/instructions.md +88 -0
  6. package/roles/analyst/frame/output-template.md +52 -0
  7. package/roles/analyst/launch/instructions.md +63 -0
  8. package/roles/analyst/launch/output-template.md +50 -0
  9. package/roles/analyst/one-pager/instructions.md +75 -0
  10. package/roles/analyst/one-pager/output-template.md +38 -0
  11. package/roles/analyst/shape/instructions.md +63 -0
  12. package/roles/analyst/shape/output-template.md +52 -0
  13. package/roles/briefer/brief/instructions.md +77 -0
  14. package/roles/briefer/brief/output-template.md +37 -0
  15. package/roles/config.yaml +130 -0
  16. package/roles/evaluator/score/instructions.md +84 -0
  17. package/roles/evaluator/score/output-template.md +11 -0
  18. package/roles/initiator/init/instructions.md +111 -0
  19. package/roles/initiator/init/output-template-competitors.md +16 -0
  20. package/roles/initiator/init/output-template-ecosystem.md +19 -0
  21. package/roles/initiator/init/output-template-product.md +136 -0
  22. package/roles/prototyper/prototype/instructions.md +146 -0
  23. package/roles/prototyper/prototype/output-template.md +10 -0
  24. package/roles/reporter/readout/instructions.md +54 -0
  25. package/roles/reporter/readout/output-template.md +37 -0
  26. package/roles/scout/matchup/instructions.md +74 -0
  27. package/roles/scout/matchup/output-template.md +115 -0
  28. package/roles/skills/README.md +19 -0
  29. package/roles/skills/critique.md +64 -0
  30. package/roles/skills/head-to-head.md +88 -0
  31. package/roles/skills/jtbd.md +43 -0
  32. package/roles/skills/landscape-scan.md +77 -0
  33. package/roles/skills/metrics.md +58 -0
  34. package/roles/skills/positioning.md +44 -0
  35. package/roles/skills/prioritization.md +101 -0
  36. package/roles/skills/product-readout.md +98 -0
  37. package/roles/skills/tech-constraints.md +27 -0
  38. package/roles/skills/ux-principles.md +24 -0
  39. package/roles/skills/win-conditions.md +68 -0
  40. package/skill/SKILL.md +231 -0
  41. package/skill/scripts/deliberate.mjs +44 -0
  42. package/src/cli/deliberate.mjs +628 -0
  43. package/src/engine/app-boot.mjs +17 -0
  44. package/src/engine/briefs.mjs +101 -0
  45. package/src/engine/cases.mjs +17 -0
  46. package/src/engine/commands.mjs +75 -0
  47. package/src/engine/init.mjs +34 -0
  48. package/src/engine/layout.mjs +37 -0
  49. package/src/engine/log.mjs +22 -0
  50. package/src/engine/matchups.mjs +87 -0
  51. package/src/engine/onepager.mjs +51 -0
  52. package/src/engine/pipeline.mjs +134 -0
  53. package/src/engine/projects.mjs +17 -0
  54. package/src/engine/prompts.mjs +28 -0
  55. package/src/engine/prototype.mjs +86 -0
  56. package/src/engine/readout-charts.mjs +217 -0
  57. package/src/engine/readouts.mjs +132 -0
  58. package/src/engine/roles.mjs +137 -0
  59. package/src/engine/scaffold.mjs +54 -0
  60. package/src/engine/score.mjs +66 -0
  61. package/src/engine/service.mjs +18 -0
@@ -0,0 +1,628 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deliberate — Deliberate CLI. Local-first, files-first product-decision pipeline.
4
+ * The project is simply the folder the harness runs in (its `deliberate/` folder).
5
+ *
6
+ * The command surface is the SINGLE SOURCE OF TRUTH in src/engine/commands.mjs
7
+ * (CLI_COMMANDS + SKILL_COMMANDS); `help` renders the engine grammar, while
8
+ * `help --skill` renders the user-facing /deliberate grammar. Tests keep both
9
+ * outputs, the generated docs, and this dispatcher in sync.
10
+ */
11
+ import { openVault } from 'sonorance/plugins/deliberate/vault.mjs';
12
+ import { setCurrentProject, openProjectVault, stagePrompt, persistStage, onepagerPrompt, persistOnepager, scorePrompt, persistScore, prototypePrompt, persistPrototype, briefPrompt, persistBrief, briefPeriodLabel, readoutPrompt, persistReadout, readoutPeriodLabel, renderTrendChartFile, loadReadoutCharts, matchupPrompt, persistMatchup, matchupAsOfLabel, initPrompt } from '../engine/service.mjs';
13
+ import { STAGES } from 'sonorance/plugins/deliberate/stages.mjs';
14
+ import { scoreClass, STATE } from 'sonorance/plugins/deliberate/domain.mjs';
15
+ import { CLI_COMMANDS, SKILL_COMMANDS } from '../engine/commands.mjs';
16
+ import { serveInfoPath } from 'sonorance/plugins/deliberate/paths.mjs';
17
+ import { configureTelemetry, emit, shutdownTelemetry } from 'sonorance/telemetry.mjs';
18
+ import { ensureInstallId } from 'sonorance/identity.mjs';
19
+ import { submitFeedback } from 'sonorance/feedback.mjs';
20
+ import { CommentClientError, fetchCommentBatch, fetchCommentProject, resolveComment } from 'sonorance/comment-client.mjs';
21
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, cpSync, rmSync, realpathSync } from 'node:fs';
22
+ import { resolve, join, dirname } from 'node:path';
23
+ import { homedir } from 'node:os';
24
+ import { spawn } from 'node:child_process';
25
+ import { fileURLToPath, pathToFileURL } from 'node:url';
26
+ import { vaultIgnoreEntries } from 'sonorance/plugins/deliberate/gitignore.mjs';
27
+
28
+ const c = { g: '\x1b[32m', y: '\x1b[33m', r: '\x1b[31m', d: '\x1b[90m', w: '\x1b[1m', x: '\x1b[0m' };
29
+ const sc = (n) => n == null ? '—' : c[scoreClass(n)] + n.toFixed(1) + c.x;
30
+ const VERDICT = { g: 'go', y: 'lean', r: 'no-go' };
31
+ const verdictOf = (n) => n == null ? null : VERDICT[scoreClass(n)];
32
+ const P = (...a) => console.log(...a);
33
+ class CliError extends Error {}
34
+
35
+ // Assigned in main() so the module can be imported (by tests) without running or
36
+ // touching ~/.sonorance — the handlers reference these lazily, at call time.
37
+ let store, A;
38
+
39
+ const setLast = (id) => setCurrentProject(store, id);
40
+ // The project is exactly the folder the harness is running in. Never fall back to
41
+ // another globally selected vault: a command from the wrong cwd must not mutate it.
42
+ const canonicalDir = (dir) => { try { return realpathSync(dir); } catch { return resolve(dir); } };
43
+ const curProject = () => {
44
+ const cwd = canonicalDir(process.cwd());
45
+ return store.listProjects().find(p => canonicalDir(p.dir) === cwd) || null;
46
+ };
47
+ const requireProject = () => {
48
+ const p = curProject();
49
+ if (!p) throw new CliError(`no Deliberate project in ${process.cwd()} — run deliberate init here`);
50
+ return p;
51
+ };
52
+
53
+ const opt = (flag) => { const i = A.indexOf(flag); return i >= 0 ? A[i + 1] : null; };
54
+
55
+ // Add ignore `entries` to `<root>/.gitignore` — but ONLY if that file already exists (we never
56
+ // create one). Idempotent (an entry already present, with or without a trailing slash, is skipped).
57
+ // Returns the entries actually added. Machine state (the platform `.sonorance/` and any hidden
58
+ // dot-subfolder a skill writes under `deliberate/`) shouldn't be committed.
59
+ const ensureGitignore = (root, entries) => {
60
+ const gi = join(root, '.gitignore');
61
+ if (!existsSync(gi)) return [];
62
+ const txt = readFileSync(gi, 'utf8');
63
+ const present = new Set(txt.split(/\r?\n/).map(l => l.trim().replace(/\/+$/, '')).filter(Boolean));
64
+ const missing = entries.filter(e => !present.has(e.replace(/\/+$/, '')));
65
+ if (!missing.length) return [];
66
+ writeFileSync(gi, txt + (txt === '' || txt.endsWith('\n') ? '' : '\n') + missing.join('\n') + '\n');
67
+ return missing;
68
+ };
69
+ // A user (and the skill) address a case by its internal hash id (or an unambiguous id
70
+ // prefix). There is no sequential number — the id is the only handle.
71
+ const resolveCase = (pid, ref) => {
72
+ if (ref == null) return null;
73
+ const r = String(ref), list = store.listCases(pid);
74
+ const exact = list.find(s => s.id === r);
75
+ if (exact) return exact.id;
76
+ const matches = list.filter(s => s.id.startsWith(r));
77
+ if (matches.length === 1) return matches[0].id;
78
+ if (!matches.length) throw new CliError(`case not found: ${r}`);
79
+ throw new CliError(`ambiguous case reference "${r}" — matches ${matches.map(s => s.id).join(', ')}; use a longer prefix`);
80
+ };
81
+ // The case a stage command acts on: an explicit trailing ref (id; not a flag), else the active case.
82
+ const targetCase = (pid, maybeRef) => {
83
+ const ref = (maybeRef != null && !String(maybeRef).startsWith('-')) ? maybeRef : null;
84
+ const id = ref != null ? resolveCase(pid, ref) : store.getActiveCase(pid);
85
+ return id != null ? store.getCase(id) : undefined;
86
+ };
87
+ const readStdin = () => new Promise(res => { if (process.stdin.isTTY) return res(''); let d = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', ch => d += ch); process.stdin.on('end', () => res(d)); });
88
+ const provisionalTitle = (s) => s ? s.split(/\s+/).slice(0, 6).join(' ').slice(0, 60) : 'Untitled';
89
+ const relDays = (ts) => { if (!ts) return ''; const d = Math.floor((Date.now() - ts) / 86400000); return d <= 0 ? 'today' : d === 1 ? '1 day ago' : `${d} days ago`; };
90
+ const firstArg = (r) => r.find(x => !x.startsWith('--')) ?? '';
91
+ // Open a URL in the user's default browser (`serve --open`). Best-effort, cross-platform.
92
+ function openBrowser(url) {
93
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
94
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
95
+ try { spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); } catch { /* ignore */ }
96
+ }
97
+ // The analysis funnel (frame → shape → launch) the in-harness skill drives one stage at a
98
+ // time; the decorrelated score and the clickable prototype are separate, recomputable
99
+ // companions (deliberate case score / case prototype), not part of the funnel.
100
+ // The running app daemon (deliberate serve) that the comment bridge talks to.
101
+ // `address` and `resolve` are HTTP clients of it — the agent (this harness) and the
102
+ // browser meet through that local server. Port resolution: --port, else $DELIBERATE_PORT,
103
+ // else the port `serve` recorded for THIS project's vault (deliberate/.config/serve.json),
104
+ // else the default — so a session in the project folder reaches the right server.
105
+ const daemonPort = () => {
106
+ if (opt('--port')) return +opt('--port');
107
+ if (process.env.DELIBERATE_PORT) return +process.env.DELIBERATE_PORT;
108
+ try { const p = curProject(); if (p) { const info = JSON.parse(readFileSync(serveInfoPath(p.dir), 'utf8')); if (info && info.port && pidAlive(info.pid)) return +info.port; } } catch { /* no pointer */ }
109
+ return 7777;
110
+ };
111
+ // A recorded server is only trustworthy if still alive: probe with `process.kill(pid, 0)` (no
112
+ // signal sent). Lenient — only a definitively-dead pid (ESRCH) is rejected, so a stale serve.json
113
+ // from a crashed server no longer points `address`/`resolve` at a dead port; a missing pid is trusted.
114
+ const pidAlive = (pid) => { if (!Number.isInteger(pid)) return true; try { process.kill(pid, 0); return true; } catch (e) { return e.code !== 'ESRCH'; } };
115
+ const commentTarget = async () => {
116
+ const p = requireProject();
117
+ const port = daemonPort();
118
+ const unavailable = `could not reach the app on port ${port} — is it running? (deliberate serve)`;
119
+ const base = `http://localhost:${port}`;
120
+ const project = await fetchCommentProject(`${base}/api/state?project=${encodeURIComponent(p.id)}`, unavailable);
121
+ if (canonicalDir(project.dir) !== canonicalDir(p.dir)) {
122
+ throw new CommentClientError(`project check failed: app on port ${port} is serving a different project`);
123
+ }
124
+ return { p, base, unavailable };
125
+ };
126
+ // This checkout's version (for the serve pointer / stale-server detection). Best-effort.
127
+ const pkgVersion = () => { try { return JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json'), 'utf8')).version || '0'; } catch { return '0'; } };
128
+ export const installEngineConfig = (repoRoot, engineFile, version = pkgVersion()) =>
129
+ existsSync(join(repoRoot, '.git'))
130
+ ? { engine: engineFile }
131
+ : { package: 'deliberate-cli', version };
132
+
133
+ // ---- `case` sub-handlers (noun-first; the parent dispatcher routes to these) ----
134
+ // Create a case (no run) and make it the active case; the skill then loops analysis
135
+ // prompt → (produce in-harness) → save per stage.
136
+ function caseNew(p, r) {
137
+ const idea = firstArg(r); if (!idea) return P('usage: deliberate case "<idea>"');
138
+ const s = store.createCase(p.id, opt('--title') || provisionalTitle(idea), idea);
139
+ store.setActiveCase(p.id, s.id);
140
+ emit('case.created', {});
141
+ P(`case ${c.w}${s.id}${c.x} · ${s.slug} ${c.d}(active)${c.x}`);
142
+ P(`stages: ${STAGES.join(' → ')} ${c.d}(score any time; prototype on request)${c.x}`);
143
+ P(`next: ${c.w}deliberate case analysis prompt${c.x}`);
144
+ }
145
+ // List cases and scores (active marked *).
146
+ function caseList(p) {
147
+ const active = store.getActiveCase(p.id);
148
+ const list = store.listCases(p.id, { min: opt('--min') ? +opt('--min') : null, state: opt('--state') });
149
+ return P(`cases · ${p.name}\n` + (list.map(s => ` ${s.id === active ? c.g + '*' + c.x : ' '} ${c.w}${s.id}${c.x} ${sc(s.score)} ${s.title} ${c.d}· ${s.state}${c.x}`).join('\n') || ' (none — deliberate case "<idea>")'));
150
+ }
151
+ // The next analysis stage to run for a case, inferred from its state (the skill never
152
+ // passes a stage token): a new case starts at frame; a mid-funnel case runs its pending
153
+ // stage; once launch is saved (state done) the analysis is complete.
154
+ const analysisStage = (kase) => {
155
+ const st = kase.state;
156
+ if (st === STATE.NEW) return STAGES[0];
157
+ return STAGES.includes(st) ? st : null;
158
+ };
159
+ // `case analysis prompt|save [id]` — walk the analysis funnel (frame → shape → launch),
160
+ // one stage at a time, inferring the stage from the case's state.
161
+ async function caseAnalysis(p, action, args) {
162
+ const kase = targetCase(p.id, args[0]);
163
+ if (!kase) return P('no case — create one with deliberate case "<idea>"');
164
+ const stage = analysisStage(kase);
165
+ if (!stage) return P(`${c.d}analysis complete for ${kase.id} — build the prototype on request →${c.x} ${c.w}deliberate case prototype prompt${c.x}`);
166
+ if (action === 'prompt') {
167
+ const { system, user } = await stagePrompt(store, p, kase, stage, opt('--note'));
168
+ return P(`MODEL: (produce in THIS session — you are the Analyst; no sub-agent) ${c.d}[stage: ${stage}]${c.x}\n===== SYSTEM =====\n${system}\n\n===== TASK =====\n${user}`);
169
+ }
170
+ if (action === 'save') {
171
+ const art = opt('--file') ? readFileSync(opt('--file'), 'utf8') : await readStdin();
172
+ if (!art.trim()) return P('usage: deliberate case analysis save [id] --file <path> (or pipe via stdin)');
173
+ const rr = await persistStage(store, p, kase.id, stage, art);
174
+ emit('case.stage.completed', { stage });
175
+ if (!rr.next) emit('case.completed', {});
176
+ return P(`saved ${stage} → ${rr.next || c.g + 'analysis complete' + c.x + c.d + ' (ask to build the prototype)' + c.x}`);
177
+ }
178
+ return P('usage: deliberate case analysis <prompt|save> [id]');
179
+ }
180
+ // `case score prompt|save [id]` — the decorrelated Evaluator's recomputable verdict
181
+ // (score.md). `prompt` prints the target Evaluator MODEL line; `save` records the
182
+ // actual model and whether an isolated evaluator produced the result.
183
+ async function caseScore(p, action, args) {
184
+ const kase = targetCase(p.id, args[0]);
185
+ if (!kase) return P('no case — create one with deliberate case "<idea>"');
186
+ if (action === 'prompt') {
187
+ const { system, user, model } = await scorePrompt(store, p, kase);
188
+ return P(`MODEL: ${model} (Evaluator — spawn an ISOLATED sub-agent on this cross-vendor model)\n===== SYSTEM =====\n${system}\n\n===== TASK =====\n${user}`);
189
+ }
190
+ if (action === 'save') {
191
+ const model = opt('--model');
192
+ if (!model) throw new CliError('case score save requires --model <actual-model-id>; add --independent only for an isolated evaluator');
193
+ if (!/^[A-Za-z0-9._:/-]{1,100}$/.test(model)) throw new CliError('--model must be a model id using letters, numbers, dot, underscore, colon, slash, or hyphen');
194
+ const art = opt('--file') ? readFileSync(opt('--file'), 'utf8') : await readStdin();
195
+ if (!art.trim()) return P('usage: deliberate case score save [id] --model <actual-model-id> [--independent] --file <path> (or pipe via stdin)');
196
+ const refreshed = kase.score != null;
197
+ const independent = A.includes('--independent');
198
+ const rr = await persistScore(store, p, kase, art, { model, independent });
199
+ if (rr.score != null) emit('case.scored', { verdict: verdictOf(rr.score), refreshed, independent });
200
+ return P(`saved score${rr.score != null ? ` · ${sc(rr.score)}` : ''} · ${independent ? 'independent evaluator' : 'same-session fallback'} → deliberate/cases/.../score.md`);
201
+ }
202
+ return P('usage: deliberate case score <prompt|save> [id]');
203
+ }
204
+ // `case one-pager prompt|save [id]` — the internal reverse-PR-FAQ (customer-voice)
205
+ // companion beside analysis.md (CLI-only, host-internal; not on the skill surface).
206
+ async function caseOnepager(p, action, args) {
207
+ const kase = targetCase(p.id, args[0]);
208
+ if (!kase) return P('no case — create one with deliberate case "<idea>"');
209
+ if (action === 'prompt') {
210
+ const { system, user } = await onepagerPrompt(store, p, kase);
211
+ return P(`MODEL: (produce in THIS session — you are the Analyst; distil the finished record)\n===== SYSTEM =====\n${system}\n\n===== TASK =====\n${user}`);
212
+ }
213
+ if (action === 'save') {
214
+ const art = opt('--file') ? readFileSync(opt('--file'), 'utf8') : await readStdin();
215
+ if (!art.trim()) return P('usage: deliberate case one-pager save [id] --file <path> (or pipe via stdin)');
216
+ const { file } = await persistOnepager(store, p, kase, art);
217
+ return P(`saved one-pager → deliberate/cases/.../one-pager.md ${c.d}(${file?.exists ? 'written' : 'saved'})${c.x}`);
218
+ }
219
+ return P('usage: deliberate case one-pager <prompt|save> [id]');
220
+ }
221
+ // `case prototype prompt|save|list [id] [--surface <slug>]` — build the clickable/recomputable
222
+ // prototype(s), a companion built on request (never auto-run), like the score. A case can hold
223
+ // one prototype per PRIMARY surface (init marks them): `--surface <slug>` targets one; omitting it
224
+ // builds the single default surface at `prototype/index.html`.
225
+ async function caseProto(p, action, args) {
226
+ const kase = targetCase(p.id, args[0]);
227
+ if (!kase) return P('no case — create one with deliberate case "<idea>"');
228
+ const surface = opt('--surface') || '';
229
+ if (action === 'list') {
230
+ const built = store.listPrototypes(kase.id);
231
+ return P(built.length
232
+ ? built.map(b => `${b.surface || c.d + '(default)' + c.x} ${c.d}→ ${b.rel}${c.x}`).join('\n')
233
+ : `${c.d}no prototype yet — build one with${c.x} deliberate case prototype prompt ${kase.id}`);
234
+ }
235
+ if (action === 'prompt') {
236
+ const { system, user } = await prototypePrompt(store, p, kase, surface);
237
+ return P(`MODEL: (produce in THIS session — you are the Prototyper; no sub-agent)\n===== SYSTEM =====\n${system}\n\n===== TASK =====\n${user}`);
238
+ }
239
+ if (action === 'save') {
240
+ const art = opt('--file') ? readFileSync(opt('--file'), 'utf8') : await readStdin();
241
+ if (!art.trim()) return P('usage: deliberate case prototype save [id] [--surface <slug>] --file <path> (or pipe via stdin)');
242
+ const { file, surface: slug } = await persistPrototype(store, p, kase, art, surface);
243
+ emit('prototype.built', { surface: slug || 'default' });
244
+ const rel = `prototype/${slug ? slug + '/' : ''}index.html`;
245
+ return P(`saved prototype${slug ? ` (${slug})` : ''} → deliberate/cases/.../${rel} ${c.d}(${file?.exists ? 'written' : 'saved'})${c.x}`);
246
+ }
247
+ return P('usage: deliberate case prototype <prompt|save|list> [id] [--surface <slug>]');
248
+ }
249
+ // List the project's briefs (newest first; delete one by removing its folder).
250
+ function briefList(p) {
251
+ const list = store.listBriefs(p.id);
252
+ return P(`briefs · ${p.name}\n` + (list.map(b => ` ${c.w}${b.id}${c.x} ${briefPeriodLabel(b.period_start, b.period_end)} ${c.d}· ${relDays(b.created_at)}${c.x}`).join('\n') || ' (none — deliberate brief prompt, then deliberate brief save)'));
253
+ }
254
+ // List the project's product readouts (newest first; delete one by removing its folder).
255
+ function readoutList(p) {
256
+ const list = store.listReadouts(p.id);
257
+ return P(`readouts · ${p.name}\n` + (list.map(r => ` ${c.w}${r.id}${c.x} ${readoutPeriodLabel(r.period_start, r.period_end)} ${c.d}· ${relDays(r.created_at)}${c.x}`).join('\n') || ' (none — deliberate readout prompt, then deliberate readout save)'));
258
+ }
259
+ // List the project's matchups (one per rival; newest first; delete one by removing its folder).
260
+ function matchupList(p) {
261
+ const list = store.listMatchups(p.id);
262
+ return P(`matchups · ${p.name}\n` + (list.map(m => ` ${c.w}${m.id}${c.x} ${m.competitor || m.slug} ${c.d}· as of ${matchupAsOfLabel(m.as_of || m.updated_at)} · ${relDays(m.updated_at)}${c.x}`).join('\n') || ' (none — deliberate matchup prompt <competitor>, then deliberate matchup save <competitor>)'));
263
+ }
264
+ // Fetch the open in-record comments from the running app (JSON-only, for the harness) —
265
+ // the read half of the comment bridge (`comment <id> resolve` is the write half).
266
+ async function commentList() {
267
+ const { p, base, unavailable } = await commentTarget();
268
+ const j = await fetchCommentBatch(
269
+ `${base}/api/address?project=${encodeURIComponent(p.id)}`,
270
+ unavailable,
271
+ );
272
+ return P(JSON.stringify(j));
273
+ }
274
+
275
+ export const cmds = {
276
+ help([mode] = []) {
277
+ const skill = mode === '--skill';
278
+ P(skill
279
+ ? `${c.w}Deliberate skill grammar${c.x}`
280
+ : `${c.w}deliberate${c.x} ${c.d}(local, files-first — the project is the folder you're in)${c.x}`);
281
+ for (const [cmd, desc] of skill ? SKILL_COMMANDS : CLI_COMMANDS) {
282
+ P(` ${c.w}${cmd}${c.x}\n ${c.d}${desc}${c.x}`);
283
+ }
284
+ },
285
+
286
+ // Set up the CURRENT folder (a repo root) as a project vault: the context in
287
+ // deliberate/context/, decision records under deliberate/. Name = folder. Idempotent.
288
+ // `init prompt` prints the Initiator prompt (method + the context scaffolds to fill).
289
+ async init([sub]) {
290
+ const abs = process.cwd();
291
+ const p = openProjectVault(store, abs);
292
+ setLast(p.id);
293
+ // Keep machine state out of git (if the project uses a .gitignore): the platform `.sonorance/`
294
+ // and any hidden dot-subfolder written under `deliberate/`. Never creates a .gitignore.
295
+ const ignored = ensureGitignore(abs, vaultIgnoreEntries(abs));
296
+ if (sub === 'prompt') {
297
+ const { system, user } = await initPrompt(store, p);
298
+ return P(`MODEL: (produce in THIS session — you are the Initiator; read the repo + sources and edit the context files)\n===== SYSTEM =====\n${system}\n\n===== TASK =====\n${user}`);
299
+ }
300
+ P(`${c.g}✓${c.x} Deliberate initialized in ${abs}`);
301
+ P(` project: ${c.w}${p.name}${c.x} ${c.d}(${p.id})${c.x}`);
302
+ P(` ${c.d}context lives in deliberate/context/product.md${c.x}; cases land under ${c.d}deliberate/cases/<date>-<slug>/${c.x}`);
303
+ P(` ${c.d}the root README points agents to deliberate/context/${c.x}`);
304
+ P(` ${c.d}platform config (shared, cross-skill) is in the hidden ${c.x}${c.w}.sonorance/${c.x}${c.d} sibling${c.x}`);
305
+ if (ignored.length) P(` ${c.d}gitignored machine state: ${ignored.join(', ')}${c.x}`);
306
+ P(` next → add sources: ${c.w}deliberate source add <location>${c.x}`);
307
+ P(` get the method: ${c.w}deliberate init prompt${c.x} ${c.d}(then fill deliberate/context/product.md + competitors.md)${c.x}`);
308
+ P(` first landscape: ${c.w}deliberate brief prompt${c.x}`);
309
+ },
310
+
311
+ // Start the local app: the web UI (src/ui) served over http by the
312
+ // daemon, reading/editing the vault's records. It's the app UI, not the
313
+ // pipeline — runs are driven by the /deliberate skill in your coding agent.
314
+ // Holds the process open (the listening socket keeps Node alive).
315
+ async serve() {
316
+ // Serve the folder you launch from: make it the current project so the UI shows
317
+ // THIS vault's cases — registering it if it's a deliberate/ vault not yet known.
318
+ // (Without this the daemon would fall back to the last-used project pointer.)
319
+ const abs = process.cwd();
320
+ let p = store.listProjects().find(x => resolve(x.dir) === resolve(abs));
321
+ if (!p && existsSync(join(abs, 'deliberate'))) p = openProjectVault(store, abs);
322
+ if (p) setLast(p.id);
323
+ const { startAppServer } = await import('../engine/app-boot.mjs');
324
+ const port = opt('--port') ? +opt('--port') : undefined;
325
+ const { port: boundPort } = await startAppServer(Number.isFinite(port) ? { port } : {});
326
+ const url = `http://localhost:${boundPort}`;
327
+ // Record where this server is listening so `address`/`resolve` (and tools) reach THIS
328
+ // server rather than guessing the default port. Cleared best-effort on exit.
329
+ if (p) {
330
+ try {
331
+ writeFileSync(serveInfoPath(p.dir), JSON.stringify({ port: boundPort, pid: process.pid, version: pkgVersion(), ts: Date.now() }));
332
+ const cleanup = () => { try { const cur = JSON.parse(readFileSync(serveInfoPath(p.dir), 'utf8')); if (cur.pid === process.pid) rmSync(serveInfoPath(p.dir), { force: true }); } catch {} };
333
+ process.on('exit', cleanup); process.on('SIGINT', () => { cleanup(); process.exit(0); }); process.on('SIGTERM', () => { cleanup(); process.exit(0); });
334
+ } catch { /* non-fatal */ }
335
+ }
336
+ P(`${c.g}✓${c.x} Deliberate app → ${c.w}${url}${c.x}`);
337
+ P(` ${c.d}${p ? `vault: ${p.dir}` : `no Deliberate project in ${abs} — run ${c.x}${c.w}deliberate init${c.x}${c.d} here first`}${c.x}`);
338
+ if (A.includes('--open')) openBrowser(url);
339
+ P(` ${c.d}Ctrl-C to stop.${c.x}`);
340
+ },
341
+
342
+ // Install the /deliberate skill so GitHub Copilot CLI discovers it, with the
343
+ // current engine path baked in via scripts/engine.json. Default = global
344
+ // (~/.copilot/skills); project-scoped with --here /
345
+ // --project <dir> / a positional <dir> (writes into <repo>/.github/skills).
346
+ install([target]) {
347
+ const engineFile = fileURLToPath(import.meta.url); // …/src/cli/deliberate.mjs
348
+ const repoRoot = resolve(dirname(engineFile), '..', '..');
349
+ // The canonical skill source is harness-neutral (`skill/`), NOT `.github/` (which is
350
+ // this repo's dev/git config). `install` copies it into the target harness's skills
351
+ // dir — Copilot's `.github/skills/deliberate` (project) or `~/.copilot/skills/deliberate`
352
+ // (global) today; the same copy targets `.claude/skills`, Cursor, … as those land.
353
+ const src = join(repoRoot, 'skill');
354
+ if (!existsSync(src)) return P(`skill source not found at ${src}`);
355
+ const projectDir = opt('--project') || (A.includes('--here') ? process.cwd() : (target && !target.startsWith('--') ? target : null));
356
+ const project = !!projectDir;
357
+ const dest = project ? join(resolve(projectDir), '.github', 'skills', 'deliberate') : join(homedir(), '.copilot', 'skills', 'deliberate');
358
+ mkdirSync(dirname(dest), { recursive: true });
359
+ cpSync(src, dest, { recursive: true });
360
+ const engineConfig = installEngineConfig(repoRoot, engineFile);
361
+ writeFileSync(join(dest, 'scripts', 'engine.json'), JSON.stringify(engineConfig, null, 2) + '\n');
362
+ // Global install: rewrite the launcher reference to the absolute installed path (cwd is an
363
+ // arbitrary repo). Project install: keep it relative — Copilot runs from that repo's root.
364
+ if (!project) {
365
+ const skillMd = join(dest, 'SKILL.md');
366
+ writeFileSync(skillMd, readFileSync(skillMd, 'utf8').replaceAll('.github/skills/deliberate/scripts/deliberate.mjs', join(dest, 'scripts', 'deliberate.mjs')));
367
+ }
368
+ P(`${c.g}✓${c.x} installed the /deliberate skill ${c.d}(${project ? 'project' : 'global'})${c.x}`);
369
+ P(` ${c.d}${dest}${c.x}`);
370
+ P(engineConfig.engine
371
+ ? ` engine: ${c.d}${engineFile}${c.x} ${c.d}(source checkout, via scripts/engine.json)${c.x}`
372
+ : ` engine: ${c.d}${engineConfig.package}@${engineConfig.version}${c.x} ${c.d}(pinned package, via scripts/engine.json)${c.x}`);
373
+ P(project
374
+ ? ` open ${c.w}${resolve(projectDir)}${c.x} in Copilot CLI and run ${c.w}/deliberate init${c.x}`
375
+ : ` open any repo in Copilot CLI and run ${c.w}/deliberate init${c.x}`);
376
+ },
377
+
378
+ // Context sources: URLs or local paths — recorded (with an optional inline description)
379
+ // in the hand-editable `.sonorance/sources.md`. An optional description after the location
380
+ // labels what the source is (e.g. `... add <location> "Roadmap"`). The host reads each
381
+ // source itself in-harness; nothing is cloned or fetched by the CLI.
382
+ async source([sub, ...r]) {
383
+ const p = requireProject();
384
+ if (sub === 'add') {
385
+ const pos = [];
386
+ for (let i = 0; i < r.length; i++) {
387
+ if (r[i] === '--section') { i++; continue; }
388
+ if (!String(r[i]).startsWith('--')) pos.push(r[i]);
389
+ }
390
+ const location = pos[0]; if (!location) return P('usage: deliberate source add <location> ["<description>"] [--section <section>]');
391
+ const description = pos.slice(1).join(' ') || null;
392
+ const section = opt('--section') || 'other';
393
+ store.addSource(p.id, location, description, section);
394
+ return P(`source added · ${section}${description ? c.d + ` — ${description}` + c.x : ''} ${c.d}(re-read it in-harness and update deliberate/context/product.md)${c.x}`);
395
+ }
396
+ if (sub === 'remove') { store.rmSource(p.id, r[0]); return P('removed'); }
397
+ const grouped = new Map();
398
+ for (const source of store.listSources(p.id)) {
399
+ const title = source.sectionTitle || 'Other';
400
+ if (!grouped.has(title)) grouped.set(title, []);
401
+ grouped.get(title).push(source);
402
+ }
403
+ return P([...grouped].map(([title, sources]) => `${c.w}${title}${c.x}\n${sources.map(s => ` ${s.location}${s.description ? `\n ${c.d}${s.description}${c.x}` : ''}`).join('\n')}`).join('\n') || '(none)');
404
+ },
405
+
406
+ // The `case` parent: noun-first, aggregating the whole case lifecycle. `new`/bare-idea
407
+ // creates; `list` lists; `analysis`/`score`/`one-pager`/`prototype` each expose a
408
+ // prompt|save pair (the skill loops prompt → produce in-harness → save). An unknown
409
+ // first token is treated as the idea, so `deliberate case "<idea>"` still works.
410
+ async case(r) {
411
+ const p = requireProject();
412
+ const [sub, ...rest] = r;
413
+ if (sub === 'new') return caseNew(p, rest);
414
+ if (sub === 'list') return caseList(p);
415
+ if (sub === 'analysis') return caseAnalysis(p, rest[0], rest.slice(1));
416
+ if (sub === 'score') return caseScore(p, rest[0], rest.slice(1));
417
+ if (sub === 'one-pager') return caseOnepager(p, rest[0], rest.slice(1));
418
+ if (sub === 'prototype') return caseProto(p, rest[0], rest.slice(1));
419
+ return caseNew(p, r); // friendly alias: `case "<idea>"`
420
+ },
421
+ // ---- briefs (project-scoped landscape reports) ------------------------------
422
+ // `brief prompt` prints the Briefer prompt; `brief save` persists it; `brief list`
423
+ // lists them. To read a brief, open it in the app or read its file directly.
424
+ async brief([sub]) {
425
+ const p = requireProject();
426
+ if (sub === 'list') return briefList(p);
427
+ if (sub === 'prompt') {
428
+ const { system, user } = await briefPrompt(store, p);
429
+ return P(`MODEL: (produce in THIS session — you are the Briefer; research the landscape yourself)\n===== SYSTEM =====\n${system}\n\n===== TASK =====\n${user}`);
430
+ }
431
+ if (sub === 'save') {
432
+ const art = opt('--file') ? readFileSync(opt('--file'), 'utf8') : await readStdin();
433
+ if (!art.trim()) return P('usage: deliberate brief save --file <path> (or pipe via stdin)');
434
+ const { brief, window } = await persistBrief(store, p, art);
435
+ emit('brief.completed', {});
436
+ return P(`saved brief ${c.w}${brief.id}${c.x} ${c.d}· ${briefPeriodLabel(window.start, window.end)}${c.x} → deliberate/briefs/`);
437
+ }
438
+ return P(`usage: deliberate brief <prompt|save|list>`);
439
+ },
440
+
441
+ // ---- product readouts (project-scoped metric + customer evidence reports) ---
442
+ // `readout prompt` prints the Reporter prompt; `readout save` persists a new,
443
+ // date-keyed artifact; `readout list` lists them newest first.
444
+ async readout([sub]) {
445
+ const p = requireProject();
446
+ const timeZone = opt('--timezone');
447
+ if ((sub === 'prompt' || sub === 'save') && A.includes('--timezone') && !timeZone) {
448
+ throw new CliError('--timezone requires an IANA timezone such as America/Chicago');
449
+ }
450
+ if (sub === 'chart') {
451
+ const spec = opt('--spec'), output = opt('--output');
452
+ if (!spec || !output) return P('usage: deliberate readout chart --spec <json> --output <svg>');
453
+ await renderTrendChartFile(resolve(spec), resolve(output));
454
+ return P(`saved chart → ${resolve(output)}`);
455
+ }
456
+ if (sub === 'list') return readoutList(p);
457
+ if (sub === 'prompt') {
458
+ const { system, user } = await readoutPrompt(store, p, {
459
+ periodStart: opt('--period-start'),
460
+ periodEnd: opt('--period-end'),
461
+ timeZone,
462
+ });
463
+ return P(`MODEL: (produce in THIS session — you are the Reporter; inspect the configured evidence yourself)\n===== SYSTEM =====\n${system}\n\n===== TASK =====\n${user}`);
464
+ }
465
+ if (sub === 'save') {
466
+ const file = opt('--file'), bundle = opt('--bundle');
467
+ if (file && bundle) throw new CliError('readout save accepts either --file or --bundle, not both');
468
+ const reportFile = bundle ? join(resolve(bundle), 'readout.md') : file;
469
+ const art = reportFile ? readFileSync(reportFile, 'utf8') : await readStdin();
470
+ if (!art.trim()) return P('usage: deliberate readout save [--file <path> | --bundle <dir>] (or pipe via stdin)');
471
+ if (!bundle && /!\[[^\]]+\]\((?:\.\/)?charts\/[^)]+\.svg/i.test(art)) {
472
+ throw new CliError('readout.md references local charts — save its bundle with --bundle <dir>');
473
+ }
474
+ const charts = bundle ? loadReadoutCharts(resolve(bundle), art) : [];
475
+ const { readout, window } = await persistReadout(store, p, art, {
476
+ charts,
477
+ periodStart: opt('--period-start'),
478
+ periodEnd: opt('--period-end'),
479
+ timeZone,
480
+ });
481
+ emit('readout.completed', {});
482
+ return P(`saved readout ${c.w}${readout.id}${c.x} ${c.d}· ${readoutPeriodLabel(window.start, window.end)}${c.x}${charts.length ? ` · ${charts.length} chart${charts.length === 1 ? '' : 's'}` : ''} → deliberate/readouts/`);
483
+ }
484
+ return P('usage: deliberate readout <prompt|chart|save|list> [--period-start <YYYY-MM-DD> --period-end <YYYY-MM-DD> --timezone <IANA>]');
485
+ },
486
+
487
+ // ---- matchups (project-scoped single-competitor head-to-heads) --------------
488
+ // `matchup prompt <competitor>` prints the Scout prompt for one rival; `matchup save
489
+ // <competitor>` persists it (refresh-in-place, keyed by the rival's slug); `matchup list`
490
+ // lists them. To read a matchup, open it in the app or read its file directly.
491
+ async matchup(r) {
492
+ const p = requireProject();
493
+ const [sub, ...rest] = r;
494
+ if (sub === 'list') return matchupList(p);
495
+ // The competitor is the positional args before any --flag (so multi-word names work
496
+ // unquoted, e.g. `matchup prompt Acme Corp`).
497
+ const flagIdx = rest.findIndex(a => a.startsWith('--'));
498
+ const competitor = (flagIdx === -1 ? rest : rest.slice(0, flagIdx)).join(' ').trim();
499
+ if (sub === 'prompt') {
500
+ if (!competitor) return P('usage: deliberate matchup prompt <competitor>');
501
+ const { system, user } = await matchupPrompt(store, p, competitor);
502
+ return P(`MODEL: (produce in THIS session — you are the Scout; research the rival yourself)\n===== SYSTEM =====\n${system}\n\n===== TASK =====\n${user}`);
503
+ }
504
+ if (sub === 'save') {
505
+ if (!competitor) return P('usage: deliberate matchup save <competitor> [--file <path>]');
506
+ const art = opt('--file') ? readFileSync(opt('--file'), 'utf8') : await readStdin();
507
+ if (!art.trim()) return P('usage: deliberate matchup save <competitor> --file <path> (or pipe via stdin)');
508
+ const existed = !!store.matchupForCompetitor(p.id, competitor);
509
+ const { matchup } = await persistMatchup(store, p, competitor, art);
510
+ emit('matchup.completed', { refreshed: existed });
511
+ return P(`saved matchup ${c.w}${matchup.id}${c.x} ${c.d}· ${matchup.competitor} · as of ${matchupAsOfLabel(matchup.as_of)}${c.x} → deliberate/matchups/${matchup.slug}/`);
512
+ }
513
+ return P(`usage: deliberate matchup <prompt|save|list> [competitor]`);
514
+ },
515
+
516
+ // ---- feedback (to the makers) ----------------------------------------------
517
+ // Send explicit, user-authored feedback to the product's makers. You (the agent) STRUCTURE it —
518
+ // nudge for problem-framing on ideas, repro completeness on bugs — but NEVER rewrite the user's
519
+ // words: the verbatim `message` is the source of truth (JTBD distillation happens founder-side).
520
+ // Two shapes:
521
+ // • a full JSON record: deliberate feedback --file fb.json (or pipe the JSON on stdin)
522
+ // • a quick one-liner: deliberate feedback "<message>" [--category bug|idea|praise|question|other]
523
+ // [--rating <1-5|up|down>] [--context "<why/what problem>"] [--agent-context "<safe summary>"]
524
+ // [--contact "<how to reach you>"] [--needs-framing]
525
+ // Only a bug's pasted `diagnostics` blob is scrubbed (paths/secrets); the message is stored as
526
+ // typed. A content-free `feedback` telemetry event (category + rating only) is emitted for
527
+ // cross-surface parity — never the text, never files, never prompts/completions.
528
+ async feedback(r) {
529
+ let input = null;
530
+ const fileArg = opt('--file');
531
+ if (fileArg) {
532
+ try { input = JSON.parse(readFileSync(fileArg, 'utf8')); }
533
+ catch { return P(`${c.r}could not read --file as JSON${c.x}`); }
534
+ } else {
535
+ const msg = firstArg(r);
536
+ if (!msg) {
537
+ const piped = (await readStdin()).trim(); // a harness may pipe a structured record
538
+ if (piped) { try { input = JSON.parse(piped); } catch { input = { message: piped }; } }
539
+ }
540
+ if (!input) {
541
+ if (!msg) return P('usage: deliberate feedback "<message>" [--category bug|idea|praise|question|other] [--rating <1-5|up|down>] (or --file <json>)');
542
+ const rawRating = opt('--rating');
543
+ const rating = rawRating == null ? undefined
544
+ : /^\d+$/.test(rawRating) ? Math.max(1, Math.min(5, +rawRating))
545
+ : (rawRating === 'up' || rawRating === 'down') ? rawRating : undefined;
546
+ input = {
547
+ message: msg,
548
+ category: opt('--category') || undefined,
549
+ rating,
550
+ context: opt('--context') || undefined,
551
+ agent_context: opt('--agent-context') || undefined,
552
+ contact: opt('--contact') || undefined,
553
+ needs_framing: A.includes('--needs-framing') || undefined,
554
+ };
555
+ }
556
+ }
557
+ const p = curProject();
558
+ const res = await submitFeedback(input, { surface: 'cli', product: 'deliberate', version: pkgVersion(), vaultDir: p?.dir || null });
559
+ P(`${c.g}✓${c.x} feedback sent ${c.d}(${res.id})${c.x}`);
560
+ if (res.needs_framing) P(` ${c.y}note:${c.x} ${c.d}help the user frame the PROBLEM (what they want + why) before sending — don't propose a solution, and don't rewrite their words${c.x}`);
561
+ return res;
562
+ },
563
+
564
+ // ---- the comment bridge (in-record annotations) ----------------------------
565
+ // The app (deliberate serve) lets a reader select any span of the rendered record and
566
+ // leave a comment. `comment list` fetches the batch of open comments (JSON, for the
567
+ // harness); the harness answers each in this conversation (reading/refining the record
568
+ // as needed) and marks it resolved with `comment <id> resolve`. Deliberate's counterpart
569
+ // as an anchored, GitHub-PR-review-style workflow.
570
+ async comment(r) {
571
+ if (r[0] === 'list') return commentList();
572
+ const VAL = new Set(['--note']);
573
+ const pos = [];
574
+ for (let i = 0; i < r.length; i++) {
575
+ const a = String(r[i]);
576
+ if (VAL.has(a)) { i++; continue; } // skip a value flag AND its value
577
+ if (a.startsWith('--')) continue; // boolean flag (e.g. --revised)
578
+ pos.push(a);
579
+ }
580
+ const [commentId, action] = pos;
581
+ if (!commentId || action !== 'resolve') return P('usage: deliberate comment list | deliberate comment <commentId> resolve [--note "<text>"] [--revised]');
582
+ const body = { commentId, revised: A.includes('--revised') };
583
+ const note = opt('--note') ?? pos.slice(2).join(' '); if (note) body.note = note;
584
+ const { p, base, unavailable } = await commentTarget();
585
+ body.project = p.id;
586
+ await resolveComment(
587
+ `${base}/api/resolve`,
588
+ body,
589
+ unavailable,
590
+ );
591
+ return P(`${c.g}✓${c.x} resolved comment ${commentId}`);
592
+ },
593
+ };
594
+
595
+ async function main() {
596
+ store = openVault();
597
+ A = process.argv.slice(2);
598
+ const verb = cmds[A[0]] ? A[0] : 'help';
599
+ const f = cmds[verb];
600
+ // `serve` is the surface=ui process — the app server configures its own telemetry singleton;
601
+ // double-configuring here would fight it. Everything else is a `cli` command we instrument.
602
+ if (verb === 'serve') { await f.call(cmds, A.slice(1)); store.close(); return; }
603
+ const freshInstall = ensureInstallId().fresh; // capture BEFORE configure (which mints the id)
604
+ configureTelemetry({ surface: 'cli', product: 'deliberate', version: pkgVersion(), vaultDir: curProject()?.dir || null });
605
+ if (freshInstall) emit('install.created', {});
606
+ emit('session.start', {});
607
+ const t0 = Date.now();
608
+ let ok = true;
609
+ try {
610
+ await f.call(cmds, A.slice(1));
611
+ } catch (err) {
612
+ ok = false;
613
+ try { emit('error', { command: verb, class: (err && err.constructor && err.constructor.name) || 'Error' }); } catch { /* never let telemetry break the CLI */ }
614
+ if (err instanceof CliError || err instanceof CommentClientError) {
615
+ process.stderr.write(`${err.message}\n`);
616
+ process.exitCode = 1;
617
+ } else {
618
+ throw err;
619
+ }
620
+ } finally {
621
+ try { emit('command.run', { name: verb, ok, duration_ms: Date.now() - t0 }); } catch { /* ignore */ }
622
+ await shutdownTelemetry();
623
+ store.close();
624
+ }
625
+ }
626
+
627
+ // Run only when invoked as the entry point; importing (tests) just exposes `cmds`.
628
+ if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) await main();
@@ -0,0 +1,17 @@
1
+ /**
2
+ * app-boot.mjs — boot the generic `sonorance` package (the local Markdown editor) for `deliberate
3
+ * serve`. Sonorance is the host/platform; Deliberate is a per-vault PLUGIN bundled inside the
4
+ * `sonorance` package. This entry just resolves the vault's composed engine (the base Sonorance
5
+ * engine + any enabled plugin, e.g. Deliberate) and hands it to the server — the exact same path
6
+ * the generic `sonorance serve` takes. No engine is injected FROM here; the plugin adds on top.
7
+ *
8
+ * This is the one place the `deliberate` → `sonorance` dependency is wired. `sonorance` is a
9
+ * LOCAL dependency (package.json `"sonorance": "file:../sonorance-app"`); nothing is published.
10
+ */
11
+ import { startServer } from 'sonorance';
12
+ import { resolveEngine } from 'sonorance/plugins';
13
+
14
+ export const startAppServer = async ({ root = process.cwd(), ...opts } = {}) => {
15
+ const engine = await resolveEngine(root);
16
+ return startServer({ ...opts, engine });
17
+ };