dictum-cli 0.1.0

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.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * polisher/layered.ts — offline and two-layer polishers over rules.ts.
3
+ *
4
+ * RulesPolisher: deterministic cleanup only — works with no LLM, no network,
5
+ * no API key. The fully-offline mode.
6
+ *
7
+ * LayeredPolisher: the two-layer strategy (borrowed from the wider prompt-
8
+ * optimizer ecosystem): a deterministic analyzer decides whether the LLM is
9
+ * needed at all. Drafts that already score at or above the threshold take the
10
+ * fast offline path (normalize only); everything else goes to the wrapped LLM
11
+ * polisher. Saves latency, tokens and privacy on already-good prompts.
12
+ *
13
+ * Imports only core/types.ts and sibling polisher files.
14
+ */
15
+
16
+ import type { Polisher, Template } from "../core/types.ts"
17
+ import { analyzePrompt, normalizeText } from "./rules.ts"
18
+
19
+ /** Offline polisher: deterministic normalization, no LLM involved. */
20
+ export class RulesPolisher implements Polisher {
21
+ async polish(text: string, _template: Template): Promise<string> {
22
+ return normalizeText(text)
23
+ }
24
+ }
25
+
26
+ export type LayeredOptions = {
27
+ /** Skip the LLM when the draft already scores at or above this (0–100). */
28
+ scoreThreshold: number
29
+ }
30
+
31
+ /** Two-layer polisher: rule-based gate in front of a wrapped LLM polisher. */
32
+ export class LayeredPolisher implements Polisher {
33
+ constructor(
34
+ private readonly inner: Polisher,
35
+ private readonly opts: LayeredOptions,
36
+ ) {}
37
+
38
+ async polish(text: string, template: Template): Promise<string> {
39
+ const analysis = analyzePrompt(text)
40
+ if (analysis.score >= this.opts.scoreThreshold) return normalizeText(text)
41
+ return this.inner.polish(text, template)
42
+ }
43
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * polisher/openai_compat.ts — Polisher backed by an OpenAI-compatible
3
+ * Chat Completions API (OpenAI, Groq, local LLM servers, OpenRouter, …).
4
+ *
5
+ * POST {base}/v1/chat/completions
6
+ * headers: authorization: Bearer <key>, content-type
7
+ * body: { model, messages: [{role:"system", instruction}, {role:"user", text}] }
8
+ * response: { choices: [{message: {content}}] }
9
+ */
10
+
11
+ import type { Polisher, Template } from "../core/types.ts"
12
+
13
+ export type OpenAiCompatPolisherOptions = {
14
+ apiKey: string
15
+ model: string
16
+ baseUrl: string
17
+ /** Request timeout in milliseconds. */
18
+ timeoutMs?: number
19
+ }
20
+
21
+ type ChatResponse = {
22
+ choices?: Array<{ message?: { content?: string } }>
23
+ error?: { message?: string }
24
+ }
25
+
26
+ export class OpenAiCompatPolisher implements Polisher {
27
+ private readonly apiKey: string
28
+ private readonly model: string
29
+ private readonly baseUrl: string
30
+ private readonly timeoutMs: number
31
+
32
+ constructor(opts: OpenAiCompatPolisherOptions) {
33
+ this.apiKey = opts.apiKey
34
+ this.model = opts.model
35
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "")
36
+ this.timeoutMs = opts.timeoutMs ?? 60000
37
+ }
38
+
39
+ async polish(text: string, template: Template): Promise<string> {
40
+ if (!this.apiKey) {
41
+ throw new Error("OpenAI-compatible polisher needs an API key — set OPENAI_API_KEY")
42
+ }
43
+
44
+ let res: Response
45
+ try {
46
+ res = await fetch(`${this.baseUrl}/v1/chat/completions`, {
47
+ method: "POST",
48
+ headers: {
49
+ "content-type": "application/json",
50
+ authorization: `Bearer ${this.apiKey}`,
51
+ },
52
+ body: JSON.stringify({
53
+ model: this.model,
54
+ messages: [
55
+ { role: "system", content: template.instruction },
56
+ { role: "user", content: text },
57
+ ],
58
+ }),
59
+ signal: AbortSignal.timeout(this.timeoutMs),
60
+ })
61
+ } catch (err) {
62
+ const reason = err instanceof Error ? err.message : String(err)
63
+ throw new Error(`OpenAI-compatible request failed: ${reason}`)
64
+ }
65
+
66
+ const body = (await res.json().catch(() => ({}))) as ChatResponse
67
+ if (!res.ok) {
68
+ const detail = body.error?.message ?? ""
69
+ throw new Error(`OpenAI-compatible API returned ${res.status}${detail ? `: ${detail}` : ""}`)
70
+ }
71
+
72
+ const out = body.choices?.[0]?.message?.content?.trim() ?? ""
73
+ if (!out) throw new Error("OpenAI-compatible API returned an empty response")
74
+ return out
75
+ }
76
+ }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * polisher/rules.ts — deterministic prompt analysis and normalization.
3
+ *
4
+ * The offline layer of the polisher: pure functions, no network, no LLM.
5
+ * `analyzePrompt` scores a draft 0–100 across five dimensions and lists its
6
+ * weak spots; `normalizeText` applies conservative mechanical cleanup (filler
7
+ * interjections, duplicated words, whitespace). Used by RulesPolisher and
8
+ * LayeredPolisher (layered.ts) and by assembly layers (CLI --format json,
9
+ * MCP analyze_prompt) to attach scores and rationale to results.
10
+ *
11
+ * Heuristics are intentionally thin and transparent: the strength of Dictum's
12
+ * polishing stays in the LLM layer; this layer provides a fast, private,
13
+ * dependency-free baseline and a stable score. Imports no project modules.
14
+ *
15
+ * NOTE on regexes: JS `\b` is ASCII-only and never matches inside Cyrillic
16
+ * text, so word detection uses Unicode token scanning and letter-class
17
+ * lookarounds instead.
18
+ */
19
+
20
+ export type PromptDimension = "clarity" | "specificity" | "structure" | "actionability" | "context"
21
+
22
+ export type PromptAnalysis = {
23
+ /** Total score normalized to 0–100 (sum of five 0–10 dimensions × 2). */
24
+ score: number
25
+ /** Per-dimension scores, each 0–10. */
26
+ dimensions: Record<PromptDimension, number>
27
+ /** Human-readable weak spots (dimensions scoring ≤ 6), worst first. */
28
+ issues: string[]
29
+ }
30
+
31
+ const clamp = (n: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, n))
32
+
33
+ /** Unicode-aware word tokens (letters/digits plus path-ish glue chars). */
34
+ const WORD_RE = /[\p{L}\p{N}][\p{L}\p{N}_./\\-]*/gu
35
+
36
+ function tokenize(text: string): string[] {
37
+ return text.toLowerCase().match(WORD_RE) ?? []
38
+ }
39
+
40
+ /** Count non-overlapping matches of `re` in `text` (a fresh lastIndex each call). */
41
+ function countMatches(text: string, re: RegExp): number {
42
+ return (text.match(re) ?? []).length
43
+ }
44
+
45
+ // ── Dictation-noise vocabulary (ru + en) ────────────────────────────────────
46
+
47
+ /** Single-token fillers, matched against lowercased tokens. */
48
+ const FILLER_TOKENS = new Set([
49
+ // ru
50
+ "эм",
51
+ "эмм",
52
+ "ээ",
53
+ "эээ",
54
+ "мм",
55
+ "ммм",
56
+ "ну",
57
+ "вот",
58
+ "типа",
59
+ "короче",
60
+ "значит",
61
+ // en
62
+ "um",
63
+ "umm",
64
+ "uh",
65
+ "uhh",
66
+ "erm",
67
+ "err",
68
+ "hmm",
69
+ "basically",
70
+ "kinda",
71
+ "sorta",
72
+ ])
73
+
74
+ /** Multi-word filler phrases, counted on the lowercased text. */
75
+ const FILLER_PHRASES = /(?:как бы|в общем|это самое|то есть как его|you know|i mean|sort of)/gu
76
+
77
+ /** Self-correction / false-start markers. */
78
+ const FALSE_STARTS =
79
+ /(?:вернее|точнее|нет,? стой|не так|отмени это|scratch that|actually,? no|wait,? no)/giu
80
+
81
+ /** Immediately repeated word (case-insensitive), e.g. "это это надо". */
82
+ const DUP_WORD = /(?<![\p{L}\p{N}])([\p{L}\p{N}]+)(?:\s+\1)+(?![\p{L}\p{N}])/giu
83
+
84
+ // ── Concreteness signals ────────────────────────────────────────────────────
85
+
86
+ const FILE_PATH = /[\w./\\-]+\.(?:ts|tsx|js|jsx|py|rs|go|md|json|toml|yaml|yml|css|html|wav|sh)\b/gi
87
+ const NUMBER = /(?<![\p{L}])\d+(?:[.,]\d+)?(?![\p{L}])/gu
88
+ const CODE_IDENT = /(?:[a-z0-9]+_[a-z0-9_]+|[a-z]+[A-Z][A-Za-z]*|--[a-z][\w-]+)/g
89
+ const QUOTED = /(?:"[^"\n]{2,}"|'[^'\n]{2,}'|`[^`\n]+`|«[^»\n]{2,}»)/g
90
+ const URL = /https?:\/\/\S+/gi
91
+
92
+ const VAGUE_PHRASES =
93
+ /(?:это самое|та штука|эта штука|эта фигня|что-то такое|как-то так|что-нибудь такое|that thing|this thing|some stuff|stuff like that|something like that)/giu
94
+
95
+ // ── Task-shape signals ──────────────────────────────────────────────────────
96
+
97
+ /** Imperative task verbs (ru stems + en), lowercased token prefixes. */
98
+ const TASK_VERB_PREFIXES = [
99
+ // ru (stems cover imperative + infinitive forms)
100
+ "сдела",
101
+ "добав",
102
+ "исправ",
103
+ "поправ",
104
+ "почин",
105
+ "напиш",
106
+ "написа",
107
+ "созда",
108
+ "провер",
109
+ "обнов",
110
+ "удал",
111
+ "убер",
112
+ "переимен",
113
+ "рефактор",
114
+ "зарефактор",
115
+ "настро",
116
+ "запуст",
117
+ "реализ",
118
+ "внедр",
119
+ "поменя",
120
+ "измен",
121
+ // ru — analytical / explanatory (asking to explain or analyze is a task too)
122
+ "разбер",
123
+ "покаж",
124
+ "проанализ",
125
+ "опиш",
126
+ "объясн",
127
+ "расскаж",
128
+ "оцен",
129
+ "сравн",
130
+ "уточн",
131
+ "сформулир",
132
+ "исслед",
133
+ "изуч",
134
+ "прораб",
135
+ // en
136
+ "add",
137
+ "fix",
138
+ "write",
139
+ "creat",
140
+ "implement",
141
+ "updat",
142
+ "remov",
143
+ "delet",
144
+ "renam",
145
+ "refactor",
146
+ "make",
147
+ "check",
148
+ "build",
149
+ "run",
150
+ "set",
151
+ "chang",
152
+ "test",
153
+ // en — analytical / explanatory
154
+ "explain",
155
+ "describ",
156
+ "analyz",
157
+ "show",
158
+ "summar",
159
+ "compar",
160
+ "evaluat",
161
+ "clarif",
162
+ ]
163
+
164
+ const DELIVERABLE_STEMS = [
165
+ // ru
166
+ "тест",
167
+ "функци",
168
+ "команд",
169
+ "файл",
170
+ "баг",
171
+ "скрипт",
172
+ "кнопк",
173
+ "эндпоинт",
174
+ "документ",
175
+ "коммит",
176
+ "релиз",
177
+ "модул",
178
+ "шаблон",
179
+ "конфиг",
180
+ // en
181
+ "test",
182
+ "function",
183
+ "command",
184
+ "file",
185
+ "bug",
186
+ "script",
187
+ "button",
188
+ "endpoint",
189
+ "doc",
190
+ "commit",
191
+ "release",
192
+ "module",
193
+ "template",
194
+ "config",
195
+ "feature",
196
+ "page",
197
+ "api",
198
+ ]
199
+
200
+ const OUTPUT_MARKERS =
201
+ /(?:верни|выведи|выдай|в виде|формат|таблиц|списком|return|output|print|as a list|as json|as markdown)/giu
202
+
203
+ const CONSTRAINT_MARKERS =
204
+ /(?:чтобы|должн|если|кроме|не лома|не трога|не меня|используй|без изменения|only|must|should|unless|except|don'?t touch|keep the|while keeping|use )/giu
205
+
206
+ const ENV_MARKERS =
207
+ /(?:bun|node|python|typescript|javascript|react|linux|macos|windows|docker|порт|port|верси|version|v\d+)/giu
208
+
209
+ const ACCEPTANCE_MARKERS =
210
+ /(?:критери|приемк|приёмк|провер|зелён|done when|acceptance|verify|passes|should pass|works when)/giu
211
+
212
+ // ── Scoring ─────────────────────────────────────────────────────────────────
213
+
214
+ function scoreClarity(text: string, tokens: string[]): number {
215
+ const fillers =
216
+ tokens.filter((t) => FILLER_TOKENS.has(t)).length +
217
+ countMatches(text.toLowerCase(), FILLER_PHRASES)
218
+ const falseStarts = countMatches(text, FALSE_STARTS)
219
+ const dups = countMatches(text, DUP_WORD)
220
+ return clamp(Math.round(10 - (fillers * 1.5 + falseStarts * 2 + dups * 2)), 0, 10)
221
+ }
222
+
223
+ function scoreSpecificity(text: string): number {
224
+ const concrete =
225
+ countMatches(text, FILE_PATH) +
226
+ countMatches(text, NUMBER) +
227
+ countMatches(text, CODE_IDENT) +
228
+ countMatches(text, QUOTED) +
229
+ countMatches(text, URL)
230
+ const vague = countMatches(text, VAGUE_PHRASES)
231
+ return clamp(4 + Math.min(6, concrete) - vague * 2, 0, 10)
232
+ }
233
+
234
+ function startsWithTaskVerb(tokens: string[]): boolean {
235
+ return tokens.slice(0, 3).some((t) => TASK_VERB_PREFIXES.some((stem) => t.startsWith(stem)))
236
+ }
237
+
238
+ function scoreStructure(text: string, tokens: string[]): number {
239
+ let score = 0
240
+ if (startsWithTaskVerb(tokens)) score += 3
241
+ if (/^\s*(?:[-*•]|\d+[.)])\s/m.test(text)) score += 2
242
+ if (text.length > 200 && text.includes("\n")) score += 1
243
+ const sentences = text
244
+ .split(/[.!?\n]+/)
245
+ .map((s) => tokenize(s).length)
246
+ .filter((n) => n > 0)
247
+ const avg = sentences.length ? sentences.reduce((a, b) => a + b, 0) / sentences.length : 0
248
+ if (avg > 0 && avg <= 25) score += 2
249
+ if (sentences.some((n) => n > 60)) score -= 2
250
+ if (tokens.length >= 3 && tokens.length <= 400) score += 2
251
+ return clamp(score, 0, 10)
252
+ }
253
+
254
+ function scoreActionability(text: string, tokens: string[]): number {
255
+ let score = 0
256
+ if (tokens.some((t) => TASK_VERB_PREFIXES.some((stem) => t.startsWith(stem)))) score += 4
257
+ if (tokens.some((t) => DELIVERABLE_STEMS.some((stem) => t.startsWith(stem)))) score += 3
258
+ if (countMatches(text, OUTPUT_MARKERS) > 0) score += 3
259
+ return clamp(score, 0, 10)
260
+ }
261
+
262
+ function scoreContext(text: string): number {
263
+ let score = 0
264
+ score += Math.min(6, countMatches(text, CONSTRAINT_MARKERS) * 2)
265
+ if (countMatches(text, ENV_MARKERS) > 0) score += 2
266
+ if (countMatches(text, ACCEPTANCE_MARKERS) > 0) score += 2
267
+ return clamp(score, 0, 10)
268
+ }
269
+
270
+ const ISSUE_MESSAGES: Record<PromptDimension, string> = {
271
+ clarity: "dictation noise survives — filler words, false starts or repeated words",
272
+ specificity: "few concrete details (files, names, numbers) or vague references",
273
+ structure: "no task-first structure — lead with an imperative, bullet multiple requirements",
274
+ actionability: "the task verb or the expected deliverable is unclear",
275
+ context: "no constraints or acceptance criteria to verify the result against",
276
+ }
277
+
278
+ /**
279
+ * Score a prompt draft 0–100 across five equally-weighted dimensions.
280
+ * Deterministic: the same text always produces the same analysis.
281
+ */
282
+ export function analyzePrompt(text: string): PromptAnalysis {
283
+ const tokens = tokenize(text)
284
+ if (tokens.length === 0) {
285
+ return {
286
+ score: 0,
287
+ dimensions: { clarity: 0, specificity: 0, structure: 0, actionability: 0, context: 0 },
288
+ issues: ["empty prompt"],
289
+ }
290
+ }
291
+ const dimensions: Record<PromptDimension, number> = {
292
+ clarity: scoreClarity(text, tokens),
293
+ specificity: scoreSpecificity(text),
294
+ structure: scoreStructure(text, tokens),
295
+ actionability: scoreActionability(text, tokens),
296
+ context: scoreContext(text),
297
+ }
298
+ const issues = (Object.entries(dimensions) as [PromptDimension, number][])
299
+ .filter(([, v]) => v <= 6)
300
+ .sort((a, b) => a[1] - b[1])
301
+ .map(([dim, v]) => `${ISSUE_MESSAGES[dim]} (${dim} ${v}/10)`)
302
+ const total = Object.values(dimensions).reduce((a, b) => a + b, 0) * 2
303
+ return { score: total, dimensions, issues }
304
+ }
305
+
306
+ // ── Normalization ───────────────────────────────────────────────────────────
307
+
308
+ /**
309
+ * Unambiguous filler interjections only (эм / ммм / um / uh …) with an optional
310
+ * trailing comma. Meaning-bearing words like «ну» or "like" are deliberately
311
+ * left alone — removal is conservative; real rewriting is the LLM's job.
312
+ */
313
+ const STRIP_FILLERS =
314
+ /(?<![\p{L}\p{N}])(?:э+м+|э{2,}|м{2,}|у+м+|um+|uh+|erm|err|h+m+)(?![\p{L}\p{N}]),?\s*/giu
315
+
316
+ /**
317
+ * Conservative mechanical cleanup of a dictated draft: strip unambiguous filler
318
+ * interjections, collapse immediately-repeated words and runs of whitespace,
319
+ * capitalize the first letter. Never rewrites content — deterministic and safe
320
+ * to apply to any text.
321
+ */
322
+ export function normalizeText(text: string): string {
323
+ let out = text
324
+ out = out.replace(STRIP_FILLERS, "")
325
+ out = out.replace(DUP_WORD, "$1")
326
+ out = out.replace(/[^\S\n]+/g, " ") // collapse spaces/tabs, keep newlines
327
+ out = out.replace(/\n{3,}/g, "\n\n")
328
+ out = out.replace(/ ([,.!?;:])/g, "$1") // no space before punctuation
329
+ out = out.trim()
330
+ if (out.length > 0) out = out[0]!.toUpperCase() + out.slice(1)
331
+ return out
332
+ }
@@ -0,0 +1,18 @@
1
+ ---
2
+ description: Turn a rough spoken thought into a clear, structured prompt for an AI coding agent
3
+ language: auto
4
+ ---
5
+ You are a prompt editor. The user dictated a rough thought out loud and it was transcribed by speech-to-text, so it may contain filler words, false starts, and transcription noise.
6
+
7
+ Rewrite it into a single clear, well-structured prompt suitable for an AI coding agent:
8
+
9
+ 1. Lead with the task: one short imperative sentence stating what to do.
10
+ 2. Preserve the user's intent and every concrete detail — names, numbers, file paths, identifiers, error messages — verbatim. Never invent details that were not said.
11
+ 3. If the thought contains several requirements or constraints, group them into a short bulleted list; otherwise keep it to one or two sentences.
12
+ 4. If the expected outcome or output format is clearly implied (a fixed bug, a passing test, a new command, a document), state it explicitly in one line.
13
+ 5. Remove filler, repetition, and self-corrections; resolve vague references ("it", "that thing") to the concrete referent when the transcript makes it unambiguous.
14
+ 6. If one critical detail is clearly missing and cannot be inferred, append a single line starting with "Open question:" instead of guessing.
15
+
16
+ Write the result in the same language as the transcript. Output ONLY the rewritten prompt — no preamble, no explanations, no surrounding quotes or code fences.
17
+
18
+ Transcript:
@@ -0,0 +1,13 @@
1
+ ---
2
+ description: Turn a spoken description of code changes into a Conventional Commits message
3
+ language: auto
4
+ ---
5
+ You are a commit-message writer. The user dictated a rough description of the changes they made, transcribed by speech-to-text, so it may contain filler and transcription noise.
6
+
7
+ Write a Conventional Commits message:
8
+ - A subject line `type(scope): summary` — type is one of feat, fix, docs, style, refactor, perf, test, build, ci, chore; scope is optional; summary is imperative, lowercase, no trailing period, ≤ 72 chars.
9
+ - Optionally, a blank line then a short body explaining what and why, as bullet points if there are several distinct changes.
10
+
11
+ Write in the same language as the transcript, but keep the Conventional Commits type prefix in English. Output ONLY the commit message — no preamble, no surrounding quotes or code fences.
12
+
13
+ Transcript:
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: Break a rough task description into an ordered list of atomic, dependency-tracked subtasks
3
+ language: auto
4
+ ---
5
+ You are a task decomposer. The user dictated or typed a rough description of a task; dictated text may contain filler words, false starts, and transcription noise.
6
+
7
+ Break it into an ordered list of atomic, independently-actionable subtasks for an AI coding agent. Preserve the user's intent and every concrete detail (names, numbers, file paths, identifiers) verbatim; never invent subtasks, files, or requirements that were not stated or clearly implied. Order subtasks so that each one only depends on subtasks that come before it. Use this Markdown structure:
8
+
9
+ ## Subtasks
10
+ A numbered list. For each subtask give:
11
+ - **Title**: a short imperative sentence — what to do.
12
+ - **Touches**: files or areas it is expected to affect, inferred from the transcript; if none can be inferred, write "unclear — infer from codebase".
13
+ - **Depends on**: the numbers of earlier subtasks it requires, or "none".
14
+
15
+ ## Open questions
16
+ Genuinely ambiguous points that block correct decomposition — how to split the work, unclear ordering, or missing scope — each on its own line starting with "Open question:". Omit this section if there are none.
17
+
18
+ Write in the same language as the input. Output ONLY the decomposition in Markdown — no preamble, no explanations, no surrounding code fences.
19
+
20
+ Transcript:
@@ -0,0 +1,11 @@
1
+ ---
2
+ description: Turn a rough spoken thought into a clean, structured Markdown note
3
+ language: auto
4
+ ---
5
+ You are a note editor. The user dictated a rough thought out loud, transcribed by speech-to-text, so it may contain filler words, false starts, and transcription noise.
6
+
7
+ Rewrite it into a clean, well-structured Markdown note. Preserve all concrete details. Remove filler and repetition. Use a short title heading and bullet points or short paragraphs as appropriate; keep it faithful to what was said — do not invent content.
8
+
9
+ Write in the same language as the transcript. Output ONLY the note in Markdown — no preamble, no explanations, no surrounding code fences.
10
+
11
+ Transcript:
@@ -0,0 +1,29 @@
1
+ ---
2
+ description: Expand a rough thought into a compact task spec with acceptance criteria
3
+ language: auto
4
+ ---
5
+ You are a spec writer. The user dictated or typed a rough description of a task; dictated text may contain filler words, false starts, and transcription noise.
6
+
7
+ Expand it into a compact, actionable spec for an AI coding agent. Preserve the user's intent and every concrete detail (names, numbers, file paths, identifiers) verbatim; never invent requirements that were not stated or clearly implied. Use this Markdown structure, omitting any section that would be empty:
8
+
9
+ ## Task
10
+ One or two imperative sentences: what to build or change.
11
+
12
+ ## Context
13
+ Known constraints and relevant facts taken from the user's words (stack, files, environment).
14
+
15
+ ## Requirements
16
+ - Bulleted, testable requirements — each a single verifiable statement.
17
+
18
+ ## Acceptance criteria
19
+ - How to verify the task is done: observable checks, commands to run, expected outputs.
20
+
21
+ ## Out of scope
22
+ What the user explicitly excluded, if anything.
23
+
24
+ ## Open questions
25
+ Critical unknowns worth resolving before or while working, if any.
26
+
27
+ Write in the same language as the input. Output ONLY the spec in Markdown — no preamble, no explanations, no surrounding code fences.
28
+
29
+ Transcript: