burner-cc 0.1.2 → 0.3.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.
package/src/tag-end.js ADDED
@@ -0,0 +1,94 @@
1
+ // SessionEnd hook: ask a small/fast model what the session was for, then tag it.
2
+
3
+ import { readFileSync } from 'node:fs';
4
+ import { discoverSessions } from './discover.js';
5
+ import { parseTranscript } from './parse.js';
6
+ import { loadConfig } from './config.js';
7
+ import { loadTags, setTag, resolveSessionProject } from './tags.js';
8
+ import { allowedCategories, buildDigest } from './classifier.js';
9
+
10
+ export async function readHookPayload() {
11
+ try {
12
+ const chunks = [];
13
+ for await (const chunk of process.stdin) chunks.push(chunk);
14
+ const text = Buffer.concat(chunks).toString('utf-8').trim();
15
+ if (!text) return {};
16
+ return JSON.parse(text);
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ function pickCategory(text, allowed) {
23
+ const t = String(text ?? '').toLowerCase();
24
+ for (const id of allowed) {
25
+ if (t === id || t.includes(id)) return id;
26
+ }
27
+ const word = t.match(/[a-z][a-z0-9_-]*/)?.[0];
28
+ if (word && allowed.includes(word)) return word;
29
+ return null;
30
+ }
31
+
32
+ async function classifyWithModel(digest, categories, model) {
33
+ const key = process.env.ANTHROPIC_API_KEY;
34
+ if (!key) throw new Error('ANTHROPIC_API_KEY is not set (needed for session-end tagging)');
35
+ const lines = categories.map((c) => `${c.id}: ${c.description || c.label}`).join('\n');
36
+ const prompts = (digest.prompts ?? []).slice(0, 6).map((p, i) => `${i + 1}. ${p}`).join('\n');
37
+ const tools = (digest.tools ?? []).slice(0, 8).map(([s, n]) => `${s} x${n}`).join(', ');
38
+ const user = `Pick exactly one category id for this Claude Code session.
39
+
40
+ Categories (reply with only the id):
41
+ ${lines}
42
+
43
+ Session title: ${digest.title ?? '—'}
44
+ Project: ${digest.project ?? '—'}
45
+ User prompts:
46
+ ${prompts || '(none)'}
47
+ Tools used: ${tools || '(none)'}`;
48
+
49
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
50
+ method: 'POST',
51
+ headers: {
52
+ 'x-api-key': key,
53
+ 'anthropic-version': '2023-06-01',
54
+ 'content-type': 'application/json',
55
+ },
56
+ body: JSON.stringify({ model, max_tokens: 40, messages: [{ role: 'user', content: user }] }),
57
+ signal: AbortSignal.timeout(45_000),
58
+ });
59
+ if (!res.ok) throw new Error(`Anthropic API ${res.status}: ${(await res.text()).slice(0, 300)}`);
60
+ const data = await res.json();
61
+ const text = data.content?.find((b) => b.type === 'text')?.text ?? '';
62
+ return text.trim();
63
+ }
64
+
65
+ export async function tagSessionAtEnd({ sessionId, model } = {}) {
66
+ const cfg = loadConfig();
67
+ const endModel = (model ?? cfg.tagging?.endModel ?? '').trim();
68
+ if (!endModel) return { ok: false, skipped: true, reason: 'no endModel configured' };
69
+
70
+ const id = sessionId ?? process.env.CLAUDE_SESSION_ID;
71
+ if (!id || id.includes('${')) return { ok: false, skipped: true, reason: 'no session id' };
72
+
73
+ const sessions = discoverSessions();
74
+ const s = sessions.find((x) => x.sessionId === id || x.sessionId.startsWith(id));
75
+ if (!s) return { ok: false, skipped: true, reason: 'session not found yet' };
76
+
77
+ const existing = loadTags()[s.sessionId];
78
+ if (existing && ['skill', 'manual'].includes(existing.source)) {
79
+ return { ok: true, skipped: true, reason: 'already tagged', category: existing.category };
80
+ }
81
+
82
+ const parsed = parseTranscript(s.mainFile);
83
+ const project = resolveSessionProject(s.sessionId, parsed.digest.cwd, cfg);
84
+ const allowed = allowedCategories(project, cfg);
85
+ const allowedIds = allowed.map((c) => c.id);
86
+ const digest = buildDigest(parsed, { title: s.customTitle ?? parsed.digest.aiTitle, projectName: project.name });
87
+
88
+ const answer = await classifyWithModel(digest, allowed, endModel);
89
+ const category = pickCategory(answer.split(/\r?\n/)[0], allowedIds);
90
+ if (!category) return { ok: false, error: `model answer not a category: ${answer.slice(0, 80)}` };
91
+
92
+ setTag(s.sessionId, { category, source: 'end-model', reason: answer.slice(0, 80) });
93
+ return { ok: true, sessionId: s.sessionId, category, model: endModel };
94
+ }
package/src/tagging.js ADDED
@@ -0,0 +1,122 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { analyzeSessions } from './analyze.js';
4
+ import { discoverSessions } from './discover.js';
5
+ import { parseTranscript } from './parse.js';
6
+ import { loadConfig } from './config.js';
7
+ import { loadTags, setTag, resolveSessionProject } from './tags.js';
8
+ import { allowedCategories, trainLearned, saveLearned, loadPrior } from './classifier.js';
9
+
10
+ function samePath(a, b) {
11
+ const n = (p) => path.resolve(p).replace(/[\\/]+$/, '').toLowerCase();
12
+ return n(a) === n(b);
13
+ }
14
+
15
+ // The session a /burner-tag call came from. Claude Code substitutes
16
+ // ${CLAUDE_SESSION_ID} in skills; when it doesn't, fall back to the newest
17
+ // transcript whose working directory is this one.
18
+ export function resolveCurrentSession(explicit, cwd = process.cwd()) {
19
+ const id = [explicit, process.env.CLAUDE_SESSION_ID].find((v) => v && !v.includes('${') && v.trim());
20
+ const sessions = discoverSessions();
21
+ if (id) {
22
+ const s = sessions.find((x) => x.sessionId === id || x.sessionId.startsWith(id));
23
+ if (s) return s;
24
+ }
25
+ for (const s of sessions.slice(0, 40)) {
26
+ const { digest } = parseTranscript(s.mainFile);
27
+ if (digest.cwd && samePath(digest.cwd, cwd)) return s;
28
+ }
29
+ return sessions[0] ?? null;
30
+ }
31
+
32
+ export function tagFromCli({ category, session, reason, task, source = 'skill' }) {
33
+ const cfg = loadConfig();
34
+ const s = resolveCurrentSession(session);
35
+ if (!s) throw new Error('no Claude Code sessions found');
36
+ const { digest } = parseTranscript(s.mainFile);
37
+ const project = resolveSessionProject(s.sessionId, digest.cwd, cfg);
38
+ const allowed = allowedCategories(project, cfg).map((c) => c.id);
39
+ if (!allowed.includes(category)) {
40
+ throw new Error(`"${category}" is not a category for project ${project.name}. Use one of: ${allowed.join(', ')}`);
41
+ }
42
+ const entry = setTag(s.sessionId, { category, source, reason, segment: !!task });
43
+ return { sessionId: s.sessionId, project: project.name, ...entry };
44
+ }
45
+
46
+ // SWE-chat (SALT-NLP) prompt_intent -> burner categories.
47
+ export const SWE_CHAT_LABELS = {
48
+ 'create new code': 'coding',
49
+ refactor: 'coding',
50
+ debug: 'debugging',
51
+ understand: 'research',
52
+ test: 'coding',
53
+ git: 'coding',
54
+ connect: 'coding',
55
+ other: 'other',
56
+ };
57
+
58
+ export async function fetchSweChat({ token, limit = 5000, onProgress } = {}) {
59
+ if (!token) throw new Error('SWE-chat is gated: accept its terms at https://huggingface.co/datasets/SALT-NLP/SWE-chat and set HF_TOKEN');
60
+ const where = encodeURIComponent('"prompt_intent" IS NOT NULL');
61
+ const rows = [];
62
+ for (let offset = 0; offset < limit; offset += 100) {
63
+ const url = `https://datasets-server.huggingface.co/filter?dataset=SALT-NLP/SWE-chat&config=default&split=train&where=${where}&offset=${offset}&length=100`;
64
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, signal: AbortSignal.timeout(60000) });
65
+ if (!res.ok) throw new Error(`SWE-chat fetch failed: HTTP ${res.status} ${(await res.text()).slice(0, 200)}`);
66
+ const data = await res.json();
67
+ for (const { row } of data.rows ?? []) {
68
+ const category = SWE_CHAT_LABELS[String(row.prompt_intent).toLowerCase()];
69
+ if (category && row.content) rows.push({ text: String(row.content).slice(0, 2000), category });
70
+ }
71
+ onProgress?.(rows.length);
72
+ if (!data.rows?.length || offset + 100 >= (data.num_rows_total ?? Infinity)) break;
73
+ }
74
+ return rows;
75
+ }
76
+
77
+ // Trains the learned model: user tags (weighted up - they're this user's
78
+ // own work) plus any public prior examples ({ text, category }).
79
+ export async function trainFromTags({ prior = [], localWeight = 5, bootstrapPrior = false } = {}) {
80
+ try {
81
+ const a = await analyzeSessions();
82
+ const tags = loadTags();
83
+ const examples = [];
84
+ for (const s of a.sessions) {
85
+ const t = tags[s.sessionId];
86
+ if (!t || !['skill', 'manual'].includes(t.source)) continue;
87
+ const digest = a.digests.get(s.sessionId);
88
+ if (!digest?.prompts?.length) continue;
89
+ examples.push({ digest, category: t.category, weight: localWeight, source: 'local' });
90
+ }
91
+ for (const p of prior) {
92
+ const text = String(p.text ?? '').trim();
93
+ if (!text) continue;
94
+ examples.push({ digest: { prompts: [text], tools: [] }, category: p.category, weight: 1, source: p.source ?? 'public' });
95
+ }
96
+ const classes = new Set(examples.map((e) => e.category));
97
+ if (examples.length < 6 || classes.size < 2) {
98
+ return { ok: false, examples: examples.length, message: `need at least 6 tagged sessions across 2+ categories to learn from (have ${examples.length})` };
99
+ }
100
+ const model = trainLearned(examples, { prior: bootstrapPrior ? loadPrior() : null });
101
+ saveLearned(model);
102
+ const localCount = examples.filter((e) => e.source === 'local').length;
103
+ return {
104
+ ok: true,
105
+ examples: localCount,
106
+ totalRows: model.examples,
107
+ sources: model.sources,
108
+ classes: Object.fromEntries(Object.entries(model.classes).map(([k, v]) => [k, v.docs])),
109
+ };
110
+ } catch (err) {
111
+ const code = err?.code;
112
+ if (code === 'EACCES' || code === 'EPERM') {
113
+ return { ok: false, message: `could not write model under ~/.burner (${code})` };
114
+ }
115
+ if (code === 'ENOSPC') return { ok: false, message: 'disk full — could not save model' };
116
+ return { ok: false, message: err?.message ?? String(err) };
117
+ }
118
+ }
119
+
120
+ export function readJsonl(file) {
121
+ return readFileSync(file, 'utf-8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l));
122
+ }
package/src/tags.js ADDED
@@ -0,0 +1,79 @@
1
+ import { FILES, readJson, writeJson, resolveProject } from './config.js';
2
+
3
+ // tags.json: { [sessionId]: { category, source, at, reason?, confidence?, model?, segments: [{ category, at, reason? }] } }
4
+ // source: skill | manual
5
+ // A segment marks the end of one task inside a session ("Every task" level):
6
+ // turns up to its timestamp belong to it.
7
+
8
+ export function loadTags() {
9
+ return readJson(FILES.tags, {});
10
+ }
11
+
12
+ export function saveTags(tags) {
13
+ writeJson(FILES.tags, tags);
14
+ }
15
+
16
+ export function setTag(sessionId, { category, source, reason, confidence, model, segment = false, at }) {
17
+ const tags = loadTags();
18
+ const now = at ?? new Date().toISOString();
19
+ const prev = tags[sessionId];
20
+ const entry = prev && segment ? { ...prev } : { segments: [] };
21
+ entry.category = category;
22
+ entry.source = source;
23
+ entry.at = now;
24
+ if (reason) entry.reason = String(reason).slice(0, 300);
25
+ else delete entry.reason;
26
+ if (confidence != null) entry.confidence = confidence;
27
+ else delete entry.confidence;
28
+ if (model) entry.model = model;
29
+ else delete entry.model;
30
+ if (segment) entry.segments = [...(entry.segments ?? []), { category, at: now, ...(reason ? { reason: String(reason).slice(0, 300) } : {}) }];
31
+ tags[sessionId] = entry;
32
+ saveTags(tags);
33
+ return entry;
34
+ }
35
+
36
+ export function clearTag(sessionId) {
37
+ const tags = loadTags();
38
+ delete tags[sessionId];
39
+ saveTags(tags);
40
+ }
41
+
42
+ // Splits a session's turns across its task segments by timestamp.
43
+ export function segmentTurns(turns, segments) {
44
+ if (!segments?.length) return null;
45
+ const segs = [...segments].sort((a, b) => String(a.at).localeCompare(String(b.at)));
46
+ const groups = segs.map((s) => ({ category: s.category, turns: [] }));
47
+ for (const t of turns) {
48
+ const ts = t.timestamp ?? '';
49
+ let idx = segs.findIndex((s) => ts <= s.at);
50
+ if (idx < 0) idx = segs.length - 1;
51
+ groups[idx].turns.push(t);
52
+ }
53
+ return groups;
54
+ }
55
+
56
+ // assignments.json: { [sessionId]: projectName }
57
+ // Lets one session move into a project even though it was started somewhere
58
+ // else (typically a home directory that holds work for several projects).
59
+ export function loadAssignments() {
60
+ return readJson(FILES.assignments, {});
61
+ }
62
+
63
+ export function setAssignment(sessionId, projectName) {
64
+ const a = loadAssignments();
65
+ if (projectName) a[sessionId] = projectName;
66
+ else delete a[sessionId];
67
+ writeJson(FILES.assignments, a);
68
+ }
69
+
70
+ // The project a session belongs to: its own assignment if that project still
71
+ // exists, otherwise whatever its working directory resolves to.
72
+ export function resolveSessionProject(sessionId, cwd, cfg, assignments = loadAssignments()) {
73
+ const name = assignments[sessionId];
74
+ if (name) {
75
+ const p = cfg.projects.find((x) => x.name === name);
76
+ if (p) return { ...p, implicit: false, bySession: true };
77
+ }
78
+ return resolveProject(cwd, cfg);
79
+ }
package/src/themes.js ADDED
@@ -0,0 +1,122 @@
1
+ import { DEFAULT_CATEGORIES, UNTAGGED } from './config.js';
2
+
3
+ const CAT_IDS = [...DEFAULT_CATEGORIES.map((c) => c.id), UNTAGGED.id];
4
+ export const PAPER_CATEGORY_COLORS = Object.fromEntries(DEFAULT_CATEGORIES.map((c) => [c.id, c.color.toLowerCase()]));
5
+ const PAPER_DEFAULT = PAPER_CATEGORY_COLORS;
6
+
7
+ // Per-theme category hues tuned for contrast on each background.
8
+ const PALETTES = {
9
+ paper: {
10
+ coding: '#2563eb',
11
+ debugging: '#dc2626',
12
+ planning: '#059669',
13
+ research: '#475569',
14
+ writing: '#b45309',
15
+ analysis: '#0d9488',
16
+ review: '#c2410c',
17
+ learning: '#64748b',
18
+ other: '#94a3b8',
19
+ untagged: '#a8a29e',
20
+ },
21
+ dark: {
22
+ coding: '#60a5fa',
23
+ debugging: '#f87171',
24
+ planning: '#4ade80',
25
+ research: '#94a3b8',
26
+ writing: '#fbbf24',
27
+ analysis: '#2dd4bf',
28
+ review: '#fb923c',
29
+ learning: '#cbd5e1',
30
+ other: '#9ca3af',
31
+ untagged: '#6b7280',
32
+ },
33
+ thermal: {
34
+ coding: '#1d4ed8',
35
+ debugging: '#b91c1c',
36
+ planning: '#047857',
37
+ research: '#57534e',
38
+ writing: '#a16207',
39
+ analysis: '#0f766e',
40
+ review: '#c2410c',
41
+ learning: '#78716c',
42
+ other: '#a8a29e',
43
+ untagged: '#a8a29e',
44
+ },
45
+ matrix: {
46
+ coding: '#22d3ee',
47
+ debugging: '#fb7185',
48
+ planning: '#4ade80',
49
+ research: '#86efac',
50
+ writing: '#fde047',
51
+ analysis: '#2dd4bf',
52
+ review: '#f97316',
53
+ learning: '#a3e635',
54
+ other: '#6ee7b7',
55
+ untagged: '#3f6212',
56
+ },
57
+ };
58
+
59
+ const SHARED = `
60
+ --accent-fg: var(--page-bg);
61
+ --warn: #dc2626;
62
+ --shadow: rgba(0,0,0,.12);
63
+ --shadow-lg: rgba(0,0,0,.22);
64
+ `;
65
+
66
+ function catVars(theme) {
67
+ const p = PALETTES[theme];
68
+ return CAT_IDS.map((id) => `--cat-${id}:${p[id]}`).join(';');
69
+ }
70
+
71
+ // Injected into report, receipt, and settings <style> blocks.
72
+ export function themeCss() {
73
+ return `
74
+ :root[data-theme="paper"]{
75
+ --bg:#f4f1e8;--ink:#2b2b26;--dim:#8a8478;--rule:#b8b2a2;--page-bg:#e7e2d3;
76
+ --accent:#2b2b26;--chart-bar:#b45309;--chart-bar-alt:#2563eb;
77
+ ${SHARED}${catVars('paper')}
78
+ }
79
+ :root[data-theme="dark"]{
80
+ --bg:#1c1f26;--ink:#e8eaed;--dim:#8b919c;--rule:#3d4450;--page-bg:#0f1115;
81
+ --accent:#e8eaed;--chart-bar:#fb923c;--chart-bar-alt:#60a5fa;
82
+ --warn:#f87171;--shadow:rgba(0,0,0,.45);--shadow-lg:rgba(0,0,0,.65);
83
+ ${catVars('dark')}
84
+ }
85
+ :root[data-theme="thermal"]{
86
+ --bg:#fdf6e3;--ink:#3d3424;--dim:#9a8b6e;--rule:#d4c4a0;--page-bg:#efe6c8;
87
+ --accent:#7c4a03;--chart-bar:#c2410c;--chart-bar-alt:#1d4ed8;
88
+ ${SHARED}${catVars('thermal')}
89
+ }
90
+ :root[data-theme="matrix"]{
91
+ --bg:#061408;--ink:#5efc8e;--dim:#2d6b3f;--rule:#1a4d2a;--page-bg:#020a04;
92
+ --accent:#7dffab;--chart-bar:#4ade80;--chart-bar-alt:#22d3ee;
93
+ --warn:#fb7185;--shadow:rgba(0,40,0,.5);--shadow-lg:rgba(0,60,0,.7);
94
+ ${catVars('matrix')}
95
+ }`;
96
+ }
97
+
98
+ // User-edited category colors in config override the theme palette.
99
+ export function categoryOverrideCss(categories) {
100
+ const lines = [];
101
+ for (const c of categories) {
102
+ const paper = PAPER_DEFAULT[c.id];
103
+ if (!paper || String(c.color).toLowerCase() === paper) continue;
104
+ lines.push(`--cat-${c.id}:${c.color}`);
105
+ }
106
+ if (!lines.length) return '';
107
+ return `:root{${lines.join(';')}}`;
108
+ }
109
+
110
+ export function resolveCategoryColor(theme, categoryId, storedColor) {
111
+ const paper = PAPER_DEFAULT[categoryId];
112
+ const stored = String(storedColor ?? '').toLowerCase();
113
+ if (paper && stored && stored !== paper) return storedColor;
114
+ return PALETTES[theme]?.[categoryId] ?? storedColor ?? '#888';
115
+ }
116
+
117
+ export function categoryColorsForTheme(theme, categories) {
118
+ const map = {};
119
+ for (const c of categories) map[c.id] = resolveCategoryColor(theme, c.id, c.color);
120
+ map[UNTAGGED.id] = resolveCategoryColor(theme, UNTAGGED.id, UNTAGGED.color);
121
+ return map;
122
+ }
package/src/yaml.js ADDED
@@ -0,0 +1,163 @@
1
+ // Minimal YAML for burner's own config files: block maps, block lists,
2
+ // flow lists of scalars, quoted/plain scalars, comments. Not a general YAML
3
+ // parser — anchors, multi-line strings and flow maps are not supported.
4
+
5
+ function parseScalar(raw) {
6
+ const s = raw.trim();
7
+ if (s === '' || s === '~' || s === 'null') return null;
8
+ if (s === 'true') return true;
9
+ if (s === 'false') return false;
10
+ if (/^-?\d+(\.\d+)?([eE][-+]?\d+)?$/.test(s)) return Number(s);
11
+ if (s.startsWith('"')) return JSON.parse(s);
12
+ if (s.startsWith("'")) return s.slice(1, -1).replace(/''/g, "'");
13
+ if (s.startsWith('[') && s.endsWith(']')) {
14
+ const inner = s.slice(1, -1).trim();
15
+ return inner ? splitFlow(inner).map(parseScalar) : [];
16
+ }
17
+ if (s === '{}') return {};
18
+ return s;
19
+ }
20
+
21
+ function splitFlow(s) {
22
+ const out = [];
23
+ let cur = '';
24
+ let quote = null;
25
+ for (const ch of s) {
26
+ if (quote) {
27
+ cur += ch;
28
+ if (ch === quote) quote = null;
29
+ } else if (ch === '"' || ch === "'") {
30
+ quote = ch;
31
+ cur += ch;
32
+ } else if (ch === ',') {
33
+ out.push(cur);
34
+ cur = '';
35
+ } else {
36
+ cur += ch;
37
+ }
38
+ }
39
+ out.push(cur);
40
+ return out;
41
+ }
42
+
43
+ function stripComment(line) {
44
+ let quote = null;
45
+ for (let i = 0; i < line.length; i++) {
46
+ const ch = line[i];
47
+ if (quote) {
48
+ if (ch === '\\' && quote === '"') i++;
49
+ else if (ch === quote) quote = null;
50
+ } else if (ch === '"' || ch === "'") quote = ch;
51
+ else if (ch === '#' && (i === 0 || /\s/.test(line[i - 1]))) return line.slice(0, i);
52
+ }
53
+ return line;
54
+ }
55
+
56
+ function splitKey(text) {
57
+ let quote = null;
58
+ for (let i = 0; i < text.length; i++) {
59
+ const ch = text[i];
60
+ if (quote) {
61
+ if (ch === '\\' && quote === '"') i++;
62
+ else if (ch === quote) quote = null;
63
+ } else if (ch === '"' || ch === "'") quote = ch;
64
+ else if (ch === ':' && (i + 1 === text.length || text[i + 1] === ' ')) {
65
+ return [parseScalar(text.slice(0, i)), text.slice(i + 1)];
66
+ }
67
+ }
68
+ return null;
69
+ }
70
+
71
+ export function parseYaml(text) {
72
+ const lines = [];
73
+ for (const raw of text.replace(/\r\n/g, '\n').split('\n')) {
74
+ const line = stripComment(raw).replace(/\s+$/, '');
75
+ if (!line.trim()) continue;
76
+ lines.push({ indent: line.length - line.trimStart().length, text: line.trim() });
77
+ }
78
+ let i = 0;
79
+
80
+ function block(indent) {
81
+ if (i >= lines.length) return null;
82
+ return lines[i].text.startsWith('- ') || lines[i].text === '-' ? list(lines[i].indent) : map(lines[i].indent);
83
+ }
84
+
85
+ function valueAfter(rest, parentIndent) {
86
+ if (rest.trim() !== '') return parseScalar(rest);
87
+ if (i < lines.length && lines[i].indent > parentIndent) return block(lines[i].indent);
88
+ if (i < lines.length && lines[i].indent === parentIndent && lines[i].text.startsWith('- ')) return list(parentIndent);
89
+ return null;
90
+ }
91
+
92
+ function map(indent) {
93
+ const obj = {};
94
+ while (i < lines.length && lines[i].indent === indent && !lines[i].text.startsWith('- ')) {
95
+ const kv = splitKey(lines[i].text);
96
+ if (!kv) throw new Error(`yaml: expected "key: value" at "${lines[i].text}"`);
97
+ i++;
98
+ obj[kv[0]] = valueAfter(kv[1], indent);
99
+ }
100
+ return obj;
101
+ }
102
+
103
+ function list(indent) {
104
+ const arr = [];
105
+ while (i < lines.length && lines[i].indent === indent && (lines[i].text.startsWith('- ') || lines[i].text === '-')) {
106
+ const rest = lines[i].text.slice(1).trimStart();
107
+ const kv = rest ? splitKey(rest) : null;
108
+ if (kv) {
109
+ // "- key: value" opens a map whose remaining keys sit at the item's content column.
110
+ const childIndent = indent + (lines[i].text.length - rest.length);
111
+ lines[i] = { indent: childIndent, text: rest };
112
+ arr.push(map(childIndent));
113
+ } else {
114
+ i++;
115
+ arr.push(valueAfter(rest, indent));
116
+ }
117
+ }
118
+ return arr;
119
+ }
120
+
121
+ return block(0) ?? {};
122
+ }
123
+
124
+ const PLAIN = /^[A-Za-z_][A-Za-z0-9_.\-/ ]*$/;
125
+ const RESERVED = /^(true|false|null|~|yes|no|on|off)$/i;
126
+
127
+ function scalar(v) {
128
+ if (v === null || v === undefined) return 'null';
129
+ if (typeof v === 'number' || typeof v === 'boolean') return String(v);
130
+ const s = String(v);
131
+ if (PLAIN.test(s) && !RESERVED.test(s) && !s.endsWith(' ')) return s;
132
+ return JSON.stringify(s);
133
+ }
134
+
135
+ function isScalar(v) {
136
+ return v === null || typeof v !== 'object';
137
+ }
138
+
139
+ export function stringifyYaml(value, indent = 0) {
140
+ const pad = ' '.repeat(indent);
141
+ if (Array.isArray(value)) {
142
+ if (!value.length) return '[]';
143
+ return value.map((item) => {
144
+ if (isScalar(item)) return `${pad}- ${scalar(item)}`;
145
+ if (Array.isArray(item)) return `${pad}-\n${stringifyYaml(item, indent + 2)}`;
146
+ const body = stringifyYaml(item, indent + 2);
147
+ return `${pad}- ${body.slice(indent + 2)}`;
148
+ }).join('\n');
149
+ }
150
+ const entries = Object.entries(value ?? {}).filter(([, v]) => v !== undefined);
151
+ if (!entries.length) return `${pad}{}`;
152
+ return entries.map(([k, v]) => {
153
+ const key = scalar(k);
154
+ if (isScalar(v)) return `${pad}${key}: ${scalar(v)}`;
155
+ if (Array.isArray(v)) {
156
+ if (!v.length) return `${pad}${key}: []`;
157
+ if (v.every(isScalar) && v.length <= 12) return `${pad}${key}: [${v.map(scalar).join(', ')}]`;
158
+ return `${pad}${key}:\n${stringifyYaml(v, indent + 2)}`;
159
+ }
160
+ if (!Object.keys(v).length) return `${pad}${key}: {}`;
161
+ return `${pad}${key}:\n${stringifyYaml(v, indent + 2)}`;
162
+ }).join('\n');
163
+ }