hive-lite 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.
Files changed (33) hide show
  1. package/README.md +443 -0
  2. package/bin/hive.js +6 -0
  3. package/docs/cli-semantics.md +386 -0
  4. package/docs/skills/hive-lite-finish/SKILL.md +282 -0
  5. package/docs/skills/hive-lite-finish/agents/openai.yaml +4 -0
  6. package/docs/skills/hive-lite-finish/references/safety.md +95 -0
  7. package/docs/skills/hive-lite-finish/references/verdicts.md +123 -0
  8. package/docs/skills/hive-lite-map-maintainer/SKILL.md +203 -0
  9. package/docs/skills/hive-lite-map-maintainer/agents/openai.yaml +7 -0
  10. package/docs/skills/hive-lite-map-maintainer/references/lifecycle.md +114 -0
  11. package/docs/skills/hive-lite-map-maintainer/references/repair-rules.md +201 -0
  12. package/docs/skills/hive-lite-start-prompt/SKILL.md +283 -0
  13. package/docs/skills/hive-lite-start-prompt/agents/openai.yaml +4 -0
  14. package/docs/skills/hive-lite-start-prompt/references/input-calibration.md +82 -0
  15. package/docs/skills/hive-lite-start-prompt/references/preflight.md +116 -0
  16. package/package.json +40 -0
  17. package/src/cli.js +910 -0
  18. package/src/lib/change.js +642 -0
  19. package/src/lib/context.js +1104 -0
  20. package/src/lib/evidence.js +230 -0
  21. package/src/lib/fsx.js +54 -0
  22. package/src/lib/git.js +128 -0
  23. package/src/lib/glob.js +47 -0
  24. package/src/lib/health.js +1012 -0
  25. package/src/lib/id.js +13 -0
  26. package/src/lib/map.js +713 -0
  27. package/src/lib/next.js +341 -0
  28. package/src/lib/risk.js +122 -0
  29. package/src/lib/roles.js +109 -0
  30. package/src/lib/scope.js +168 -0
  31. package/src/lib/skills.js +349 -0
  32. package/src/lib/status.js +344 -0
  33. package/src/lib/yaml.js +223 -0
@@ -0,0 +1,344 @@
1
+ const { createHash } = require('crypto');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { changedFiles, currentBranch, currentHead, diffFromHead, isGitRepo, repoRoot } = require('./git');
5
+ const { latestChangeId, loadChange } = require('./change');
6
+ const { exists, readJson } = require('./fsx');
7
+ const { hiveDir } = require('./map');
8
+
9
+ function sha256(value) {
10
+ return createHash('sha256').update(value || '').digest('hex');
11
+ }
12
+
13
+ function diffHash(text) {
14
+ return `sha256:${sha256(text)}`;
15
+ }
16
+
17
+ function accepted(change) {
18
+ return change && change.humanDecision && change.humanDecision.status === 'accepted';
19
+ }
20
+
21
+ function committed(change) {
22
+ return Boolean(change && change.humanDecision && change.humanDecision.commit);
23
+ }
24
+
25
+ function latestChange(root) {
26
+ const id = latestChangeId(root);
27
+ if (!id) return { id: null, change: null };
28
+ try {
29
+ return { id, change: loadChange(root, id) };
30
+ } catch {
31
+ return { id, change: null };
32
+ }
33
+ }
34
+
35
+ function acceptedChangesBySplit(root) {
36
+ const dir = path.join(hiveDir(root), 'changes');
37
+ const accepted = new Map();
38
+ if (!exists(dir)) return accepted;
39
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
40
+ if (!entry.isDirectory() || !entry.name.startsWith('chg_')) continue;
41
+ const file = path.join(dir, entry.name, 'change.json');
42
+ if (!exists(file)) continue;
43
+ try {
44
+ const change = readJson(file);
45
+ const origin = change.originSplit || {};
46
+ if (!origin.splitId || !origin.phaseId) continue;
47
+ if (!change.humanDecision || change.humanDecision.status !== 'accepted') continue;
48
+ const bySplit = accepted.get(origin.splitId) || new Map();
49
+ const list = bySplit.get(origin.phaseId) || [];
50
+ list.push({
51
+ changeId: change.id,
52
+ commit: change.humanDecision.commit || null,
53
+ });
54
+ bySplit.set(origin.phaseId, list);
55
+ accepted.set(origin.splitId, bySplit);
56
+ } catch {
57
+ // Ignore malformed historical change records.
58
+ }
59
+ }
60
+ return accepted;
61
+ }
62
+
63
+ function sortSplitNotes(notes) {
64
+ return notes.sort((a, b) => (
65
+ String(b.createdAt).localeCompare(String(a.createdAt)) || String(b.id).localeCompare(String(a.id))
66
+ ));
67
+ }
68
+
69
+ function splitIsComplete(split) {
70
+ const progress = split.derivedProgress || {};
71
+ return split.phaseCount > 0 && (progress.remainingPhases || []).length === 0;
72
+ }
73
+
74
+ function splitHasAcceptedProgress(split) {
75
+ const progress = split.derivedProgress || {};
76
+ return (progress.acceptedPhases || []).length > 0;
77
+ }
78
+
79
+ function splitHasReadyPhase(split) {
80
+ const progress = split.derivedProgress || {};
81
+ return (progress.readyPhases || []).length > 0;
82
+ }
83
+
84
+ function splitSummaryItem(split) {
85
+ if (!split) return null;
86
+ return {
87
+ id: split.id,
88
+ createdAt: split.createdAt,
89
+ originalIntent: split.originalIntent,
90
+ suggestedNextPhase: split.suggestedNextPhase || null,
91
+ };
92
+ }
93
+
94
+ function primaryRecentSplitNote(shownNotes) {
95
+ return shownNotes.find(splitHasReadyPhase) || shownNotes[0] || null;
96
+ }
97
+
98
+ function defaultVisibleSplitNotes(notes, limit) {
99
+ const active = notes.filter((split) => (
100
+ !splitIsComplete(split)
101
+ && splitHasAcceptedProgress(split)
102
+ && ((split.derivedProgress.remainingPhases || []).length > 0)
103
+ ));
104
+ if (active.length > 0) return active.slice(0, limit);
105
+
106
+ return notes
107
+ .filter((split) => !splitIsComplete(split) && splitHasReadyPhase(split))
108
+ .slice(0, Math.min(limit, 1));
109
+ }
110
+
111
+ function splitNoteSummary(allNotes, shownNotes, policy) {
112
+ const shown = new Set(shownNotes.map((split) => split.id));
113
+ const primary = primaryRecentSplitNote(shownNotes);
114
+ const activeWithProgress = allNotes.filter((split) => !splitIsComplete(split) && splitHasAcceptedProgress(split));
115
+ return {
116
+ total: allNotes.length,
117
+ shown: shownNotes.length,
118
+ suppressed: allNotes.filter((split) => !shown.has(split.id)).length,
119
+ activeWithProgress: activeWithProgress.length,
120
+ unstartedCandidates: allNotes.filter((split) => !splitIsComplete(split) && !splitHasAcceptedProgress(split)).length,
121
+ completed: allNotes.filter(splitIsComplete).length,
122
+ primaryRecentSplitNote: splitSummaryItem(primary),
123
+ otherActiveSplitNotes: activeWithProgress
124
+ .filter((split) => !primary || split.id !== primary.id)
125
+ .map(splitSummaryItem),
126
+ policy,
127
+ };
128
+ }
129
+
130
+ function emptySplitNoteSummary(policy = 'active_progress_or_latest_unstarted') {
131
+ return {
132
+ total: 0,
133
+ shown: 0,
134
+ suppressed: 0,
135
+ activeWithProgress: 0,
136
+ unstartedCandidates: 0,
137
+ completed: 0,
138
+ primaryRecentSplitNote: null,
139
+ otherActiveSplitNotes: [],
140
+ policy,
141
+ };
142
+ }
143
+
144
+ function splitNotesOverview(root, options = {}) {
145
+ const dir = path.join(hiveDir(root), 'context', 'splits');
146
+ const includeAll = Boolean(options.includeAll);
147
+ const policy = includeAll ? 'all' : 'active_progress_or_latest_unstarted';
148
+ if (!exists(dir)) return { notes: [], summary: emptySplitNoteSummary(policy) };
149
+ const accepted = acceptedChangesBySplit(root);
150
+ const allNotes = sortSplitNotes(fs.readdirSync(dir, { withFileTypes: true })
151
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json') && entry.name.startsWith('split_'))
152
+ .map((entry) => {
153
+ const file = path.join(dir, entry.name);
154
+ try {
155
+ const note = readJson(file);
156
+ const phases = Array.isArray(note.phases) ? note.phases : [];
157
+ const acceptedForSplit = accepted.get(note.id) || new Map();
158
+ const acceptedPhases = phases.filter((phase) => acceptedForSplit.has(phase.id)).map((phase) => phase.id);
159
+ const remaining = phases.filter((phase) => !acceptedForSplit.has(phase.id));
160
+ const ready = [];
161
+ const waiting = [];
162
+ for (const phase of remaining) {
163
+ const required = ((phase.preconditions || {}).requiredAcceptedPhases || []);
164
+ const missing = required.filter((phaseId) => !acceptedForSplit.has(phaseId));
165
+ const item = {
166
+ phaseId: phase.id,
167
+ title: phase.title,
168
+ findIntent: phase.findIntent,
169
+ missingRequiredAcceptedPhases: missing,
170
+ };
171
+ if (missing.length) waiting.push(item);
172
+ else ready.push(item);
173
+ }
174
+ return {
175
+ id: note.id,
176
+ createdAt: note.createdAt || fs.statSync(file).mtime.toISOString(),
177
+ originalIntent: note.source ? note.source.originalIntent || '' : note.originalIntent || '',
178
+ markdownPath: path.relative(root, path.join(dir, `${note.id}.md`)).replace(/\\/g, '/'),
179
+ phaseCount: phases.length,
180
+ derivedProgress: {
181
+ acceptedPhases,
182
+ remainingPhases: remaining.map((phase) => phase.id),
183
+ readyPhases: ready,
184
+ waitingPhases: waiting,
185
+ isComplete: phases.length > 0 && remaining.length === 0,
186
+ hasAcceptedProgress: acceptedPhases.length > 0,
187
+ },
188
+ suggestedNextPhase: ready[0] || null,
189
+ };
190
+ } catch {
191
+ return null;
192
+ }
193
+ })
194
+ .filter(Boolean));
195
+ const limit = includeAll ? allNotes.length : 5;
196
+ const notes = includeAll ? allNotes : defaultVisibleSplitNotes(allNotes, limit);
197
+ return {
198
+ notes,
199
+ summary: splitNoteSummary(allNotes, notes, policy),
200
+ };
201
+ }
202
+
203
+ function action(kind, label, command, description) {
204
+ return { kind, label, command, description };
205
+ }
206
+
207
+ function actionsFor(state, latestId, diffMatches) {
208
+ if (state === 'repo_setup_required') {
209
+ return [
210
+ action('stop', 'Stop', null, 'Hive Lite requires a git repository before it can create Context Packets or Change Records.'),
211
+ action('change_directory', 'Switch to the correct git repo root', null, 'If this project already has git, open the repository root and rerun Hive Lite.'),
212
+ action('manual_git_setup', 'Set up git manually', null, 'If this is a new project, review .gitignore, initialize git, and create an initial commit yourself before running Hive Lite.'),
213
+ ];
214
+ }
215
+ if (state === 'clean') {
216
+ return [
217
+ action('start_requirement', 'Start a Hive Lite requirement', 'hive-lite find "<intent>" --json', 'The worktree is clean enough to generate a new Context Packet.'),
218
+ ];
219
+ }
220
+ if (state === 'unmanaged_dirty') {
221
+ return [
222
+ action('commit', 'Commit current changes', 'git add -A && git commit -m "<message>"', 'Creates a clean git boundary before starting a new requirement.'),
223
+ action('stash', 'Stash current changes', 'git stash push -u -m "wip before hive-lite"', 'Temporarily clears the worktree without accepting the changes.'),
224
+ action('worktree', 'Start in a separate worktree', 'git worktree add ../<repo>-<topic> -b <branch-name>', 'Use this when current work must remain unfinished.'),
225
+ action('stop', 'Stop', null, 'Do not start a new Hive Lite requirement from a dirty worktree.'),
226
+ ];
227
+ }
228
+ if (state === 'in_progress') {
229
+ const refresh = diffMatches ? [] : [
230
+ action('refresh_change', 'Refresh the existing Change Record', `hive-lite check ${latestId}`, 'The current diff changed since the latest Change Record was captured.'),
231
+ ];
232
+ return [
233
+ ...refresh,
234
+ action('validate', 'Validate existing Hive change', `hive-lite validate ${latestId}`, 'Run the validation plan recorded for the current change.'),
235
+ action('accept_commit', 'Accept and commit existing Hive change', `hive-lite accept ${latestId} --commit -m "<message>"`, 'Finish the current Hive change and return the worktree to clean.'),
236
+ action('worktree', 'Start in a separate worktree', 'git worktree add ../<repo>-<topic> -b <branch-name>', 'Use this when current work must remain unfinished.'),
237
+ action('stop', 'Stop', null, 'Do not start a new Hive Lite requirement until the current change is finished or isolated.'),
238
+ ];
239
+ }
240
+ if (state === 'accepted_uncommitted') {
241
+ return [
242
+ action('accept_commit', 'Commit accepted Hive change', `hive-lite accept ${latestId} --commit -m "<message>"`, 'Creates the git boundary for the accepted change.'),
243
+ action('worktree', 'Start in a separate worktree', 'git worktree add ../<repo>-<topic> -b <branch-name>', 'Use this when the accepted change must remain uncommitted here.'),
244
+ action('stop', 'Stop', null, 'Do not start a new Hive Lite requirement until the accepted change is committed or isolated.'),
245
+ ];
246
+ }
247
+ return [];
248
+ }
249
+
250
+ function evaluateWorkspaceStatus(cwd, options = {}) {
251
+ if (!isGitRepo(cwd)) {
252
+ const root = path.resolve(cwd);
253
+ const state = 'repo_setup_required';
254
+ return {
255
+ version: 1,
256
+ root,
257
+ repo: {
258
+ isGitRepo: false,
259
+ setupRequired: true,
260
+ },
261
+ branch: null,
262
+ head: null,
263
+ worktree: {
264
+ status: 'no_git',
265
+ dirty: false,
266
+ changedFiles: [],
267
+ currentDiffHash: null,
268
+ },
269
+ hive: {
270
+ state,
271
+ reason: 'Current directory is not inside a git worktree. Hive Lite requires git for diff, baseline, Change Record, and accept boundaries.',
272
+ latestChange: null,
273
+ },
274
+ canStartNewRequirement: false,
275
+ recommendedActions: actionsFor(state, null, false),
276
+ recentSplitNotes: [],
277
+ splitNoteSummary: emptySplitNoteSummary(options.all ? 'all' : 'active_progress_or_latest_unstarted'),
278
+ };
279
+ }
280
+
281
+ const root = repoRoot(cwd);
282
+ const files = changedFiles(root);
283
+ const dirty = files.length > 0;
284
+ const currentDiff = dirty ? diffFromHead(root) : '';
285
+ const currentDiffHash = dirty ? diffHash(currentDiff) : null;
286
+ const latest = latestChange(root);
287
+ const latestDiffHash = latest.change && latest.change.diff ? latest.change.diff.diffHash : null;
288
+ const diffMatches = Boolean(dirty && latestDiffHash && currentDiffHash === latestDiffHash);
289
+
290
+ let state = dirty ? 'unmanaged_dirty' : 'clean';
291
+ let reason = dirty
292
+ ? 'Working tree has dirty files that are not known to be an accepted or in-progress Hive change.'
293
+ : 'Working tree is clean.';
294
+
295
+ if (dirty && latest.change) {
296
+ if (accepted(latest.change) && !committed(latest.change) && diffMatches) {
297
+ state = 'accepted_uncommitted';
298
+ reason = 'The dirty diff matches the latest accepted Hive change, but no commit was created.';
299
+ } else if (!accepted(latest.change)) {
300
+ state = 'in_progress';
301
+ reason = diffMatches
302
+ ? 'The dirty diff matches the latest in-progress Hive Change Record.'
303
+ : 'There is an in-progress Hive Change Record, but the working diff has changed since it was captured.';
304
+ } else if (accepted(latest.change) && !committed(latest.change) && !diffMatches) {
305
+ reason = 'The latest Hive change was accepted without commit, but the current dirty diff no longer matches it.';
306
+ }
307
+ }
308
+
309
+ const latestSummary = latest.change ? {
310
+ id: latest.id,
311
+ verdict: latest.change.risk ? latest.change.risk.verdict : null,
312
+ validationStatus: latest.change.validation ? latest.change.validation.status : null,
313
+ humanDecision: latest.change.humanDecision || null,
314
+ diffHash: latestDiffHash,
315
+ currentDiffMatchesLatestChange: diffMatches,
316
+ } : null;
317
+ const splitNotes = splitNotesOverview(root, { includeAll: options.all });
318
+
319
+ return {
320
+ version: 1,
321
+ root,
322
+ branch: currentBranch(root),
323
+ head: currentHead(root),
324
+ worktree: {
325
+ status: dirty ? 'dirty' : 'clean',
326
+ dirty,
327
+ changedFiles: files,
328
+ currentDiffHash,
329
+ },
330
+ hive: {
331
+ state,
332
+ reason,
333
+ latestChange: latestSummary,
334
+ },
335
+ canStartNewRequirement: state === 'clean',
336
+ recommendedActions: actionsFor(state, latest.id, diffMatches),
337
+ recentSplitNotes: splitNotes.notes,
338
+ splitNoteSummary: splitNotes.summary,
339
+ };
340
+ }
341
+
342
+ module.exports = {
343
+ evaluateWorkspaceStatus,
344
+ };
@@ -0,0 +1,223 @@
1
+ function stripComment(line) {
2
+ let quote = null;
3
+ for (let i = 0; i < line.length; i += 1) {
4
+ const ch = line[i];
5
+ if ((ch === '"' || ch === "'") && line[i - 1] !== '\\') {
6
+ quote = quote === ch ? null : quote || ch;
7
+ }
8
+ if (ch === '#' && !quote) {
9
+ return line.slice(0, i);
10
+ }
11
+ }
12
+ return line;
13
+ }
14
+
15
+ function preprocess(input) {
16
+ return input.split(/\r?\n/)
17
+ .map((raw) => {
18
+ const withoutComment = stripComment(raw).replace(/\s+$/g, '');
19
+ const text = withoutComment.trim();
20
+ if (!text) return null;
21
+ return {
22
+ indent: withoutComment.match(/^ */)[0].length,
23
+ text,
24
+ };
25
+ })
26
+ .filter(Boolean);
27
+ }
28
+
29
+ function splitInlineArray(value) {
30
+ const items = [];
31
+ let current = '';
32
+ let quote = null;
33
+ for (let i = 0; i < value.length; i += 1) {
34
+ const ch = value[i];
35
+ if ((ch === '"' || ch === "'") && value[i - 1] !== '\\') {
36
+ quote = quote === ch ? null : quote || ch;
37
+ current += ch;
38
+ continue;
39
+ }
40
+ if (ch === ',' && !quote) {
41
+ items.push(current.trim());
42
+ current = '';
43
+ continue;
44
+ }
45
+ current += ch;
46
+ }
47
+ if (current.trim()) items.push(current.trim());
48
+ return items;
49
+ }
50
+
51
+ function parseScalar(value) {
52
+ const text = value.trim();
53
+ if (text === '[]') return [];
54
+ if (text === '{}') return {};
55
+ if (text === 'null' || text === '~') return null;
56
+ if (text === 'true') return true;
57
+ if (text === 'false') return false;
58
+ if (/^-?\d+(\.\d+)?$/.test(text)) return Number(text);
59
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
60
+ if (text.startsWith('"')) return JSON.parse(text);
61
+ return text.slice(1, -1).replace(/''/g, "'");
62
+ }
63
+ if (text.startsWith('[') && text.endsWith(']')) {
64
+ const inner = text.slice(1, -1).trim();
65
+ if (!inner) return [];
66
+ return splitInlineArray(inner).map(parseScalar);
67
+ }
68
+ return text;
69
+ }
70
+
71
+ function splitKeyValue(text) {
72
+ let quote = null;
73
+ for (let i = 0; i < text.length; i += 1) {
74
+ const ch = text[i];
75
+ if ((ch === '"' || ch === "'") && text[i - 1] !== '\\') {
76
+ quote = quote === ch ? null : quote || ch;
77
+ }
78
+ if (ch === ':' && !quote) {
79
+ return [text.slice(0, i).trim(), text.slice(i + 1).trim()];
80
+ }
81
+ }
82
+ return null;
83
+ }
84
+
85
+ function parseObject(lines, start, indent) {
86
+ const result = {};
87
+ let index = start;
88
+ while (index < lines.length) {
89
+ const line = lines[index];
90
+ if (line.indent < indent) break;
91
+ if (line.indent > indent) {
92
+ throw new Error(`unexpected indentation near "${line.text}"`);
93
+ }
94
+ if (line.text.startsWith('- ')) break;
95
+
96
+ const pair = splitKeyValue(line.text);
97
+ if (!pair || !pair[0]) throw new Error(`expected key: value near "${line.text}"`);
98
+ const [key, rest] = pair;
99
+ index += 1;
100
+ if (rest) {
101
+ result[key] = parseScalar(rest);
102
+ continue;
103
+ }
104
+
105
+ if (index >= lines.length || lines[index].indent <= indent) {
106
+ result[key] = null;
107
+ continue;
108
+ }
109
+ const parsed = parseBlock(lines, index, lines[index].indent);
110
+ result[key] = parsed.value;
111
+ index = parsed.index;
112
+ }
113
+ return { value: result, index };
114
+ }
115
+
116
+ function parseArray(lines, start, indent) {
117
+ const result = [];
118
+ let index = start;
119
+ while (index < lines.length) {
120
+ const line = lines[index];
121
+ if (line.indent < indent) break;
122
+ if (line.indent > indent) throw new Error(`unexpected indentation near "${line.text}"`);
123
+ if (!line.text.startsWith('- ')) break;
124
+
125
+ const rest = line.text.slice(2).trim();
126
+ index += 1;
127
+ if (!rest) {
128
+ if (index >= lines.length || lines[index].indent <= indent) {
129
+ result.push(null);
130
+ continue;
131
+ }
132
+ const parsed = parseBlock(lines, index, lines[index].indent);
133
+ result.push(parsed.value);
134
+ index = parsed.index;
135
+ continue;
136
+ }
137
+
138
+ const pair = splitKeyValue(rest);
139
+ if (pair && pair[0] && !rest.startsWith('"') && !rest.startsWith("'")) {
140
+ const item = {};
141
+ item[pair[0]] = pair[1] ? parseScalar(pair[1]) : null;
142
+ if (index < lines.length && lines[index].indent > indent) {
143
+ const parsed = parseObject(lines, index, lines[index].indent);
144
+ Object.assign(item, parsed.value);
145
+ index = parsed.index;
146
+ }
147
+ result.push(item);
148
+ } else {
149
+ result.push(parseScalar(rest));
150
+ }
151
+ }
152
+ return { value: result, index };
153
+ }
154
+
155
+ function parseBlock(lines, start, indent) {
156
+ if (start >= lines.length) return { value: null, index: start };
157
+ if (lines[start].text.startsWith('- ')) return parseArray(lines, start, indent);
158
+ return parseObject(lines, start, indent);
159
+ }
160
+
161
+ function parseYaml(input) {
162
+ const trimmed = input.trim();
163
+ if (!trimmed) return {};
164
+ if (trimmed.startsWith('{') || trimmed.startsWith('[')) return JSON.parse(trimmed);
165
+ const lines = preprocess(input);
166
+ if (lines.length === 0) return {};
167
+ return parseBlock(lines, 0, lines[0].indent).value;
168
+ }
169
+
170
+ function scalarToYaml(value) {
171
+ if (value === null || value === undefined) return 'null';
172
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
173
+ const text = String(value);
174
+ if (text === '' || /[:#\[\]{},]|^\s|\s$|^(true|false|null|~|-?\d+(\.\d+)?)$/.test(text)) {
175
+ return JSON.stringify(text);
176
+ }
177
+ return text;
178
+ }
179
+
180
+ function stringifyValue(value, indent) {
181
+ const pad = ' '.repeat(indent);
182
+ if (Array.isArray(value)) {
183
+ if (value.length === 0) return '[]';
184
+ return value.map((item) => {
185
+ if (item && typeof item === 'object' && !Array.isArray(item)) {
186
+ const lines = stringifyObject(item, indent + 2).split('\n');
187
+ return `${pad}- ${lines[0].trimStart()}\n${lines.slice(1).join('\n')}`;
188
+ }
189
+ if (Array.isArray(item)) {
190
+ return `${pad}- ${stringifyValue(item, indent + 2).trimStart()}`;
191
+ }
192
+ return `${pad}- ${scalarToYaml(item)}`;
193
+ }).join('\n');
194
+ }
195
+ if (value && typeof value === 'object') {
196
+ return `\n${stringifyObject(value, indent)}`;
197
+ }
198
+ return scalarToYaml(value);
199
+ }
200
+
201
+ function stringifyObject(object, indent = 0) {
202
+ const pad = ' '.repeat(indent);
203
+ return Object.entries(object).map(([key, value]) => {
204
+ if (Array.isArray(value)) {
205
+ const rendered = stringifyValue(value, indent + 2);
206
+ return rendered === '[]' ? `${pad}${key}: []` : `${pad}${key}:\n${rendered}`;
207
+ }
208
+ if (value && typeof value === 'object') {
209
+ return `${pad}${key}:\n${stringifyObject(value, indent + 2)}`;
210
+ }
211
+ return `${pad}${key}: ${scalarToYaml(value)}`;
212
+ }).join('\n');
213
+ }
214
+
215
+ function stringifyYaml(value) {
216
+ if (Array.isArray(value)) return `${stringifyValue(value, 0)}\n`;
217
+ return `${stringifyObject(value, 0)}\n`;
218
+ }
219
+
220
+ module.exports = {
221
+ parseYaml,
222
+ stringifyYaml,
223
+ };