@majeanson/lac 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.
package/dist/index.mjs ADDED
@@ -0,0 +1,2465 @@
1
+ import { Command } from "commander";
2
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import process$1 from "node:process";
4
+ import { FEATURE_KEY_PATTERN, generateFeatureKey, validateFeature } from "@life-as-code/feature-schema";
5
+ import path, { dirname, join, resolve } from "node:path";
6
+ import fs, { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
7
+ import { spawn, spawnSync } from "node:child_process";
8
+ import prompts from "prompts";
9
+ import http from "node:http";
10
+
11
+ //#region src/lib/scanner.ts
12
+ /**
13
+ * Recursively finds all feature.json files under a directory.
14
+ * Returns an array of { filePath, feature } for each valid feature.json found.
15
+ * Files that fail validation are skipped with a warning printed to stderr.
16
+ */
17
+ async function scanFeatures(dir) {
18
+ const results = [];
19
+ async function walk(currentDir) {
20
+ let entries;
21
+ let rawEntries;
22
+ try {
23
+ rawEntries = await readdir(currentDir, { withFileTypes: true });
24
+ } catch (err) {
25
+ const message = err instanceof Error ? err.message : String(err);
26
+ process.stderr.write(`Warning: could not read directory "${currentDir}": ${message}\n`);
27
+ return;
28
+ }
29
+ entries = rawEntries.map((e) => ({
30
+ name: String(e.name),
31
+ isDirectory: () => e.isDirectory(),
32
+ isFile: () => e.isFile()
33
+ }));
34
+ for (const entry of entries) {
35
+ const fullPath = join(currentDir, entry.name);
36
+ if (entry.isDirectory()) {
37
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
38
+ await walk(fullPath);
39
+ } else if (entry.isFile() && entry.name === "feature.json") {
40
+ let raw;
41
+ try {
42
+ raw = await readFile(fullPath, "utf-8");
43
+ } catch (err) {
44
+ const message = err instanceof Error ? err.message : String(err);
45
+ process.stderr.write(`Warning: could not read "${fullPath}": ${message}\n`);
46
+ continue;
47
+ }
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(raw);
51
+ } catch {
52
+ process.stderr.write(`Warning: invalid JSON in "${fullPath}" — skipping\n`);
53
+ continue;
54
+ }
55
+ const result = validateFeature(parsed);
56
+ if (!result.success) {
57
+ process.stderr.write(`Warning: "${fullPath}" failed validation — skipping\n ${result.errors.join("\n ")}\n`);
58
+ continue;
59
+ }
60
+ results.push({
61
+ filePath: fullPath,
62
+ feature: result.data
63
+ });
64
+ }
65
+ }
66
+ }
67
+ await walk(dir);
68
+ return results;
69
+ }
70
+
71
+ //#endregion
72
+ //#region src/commands/archive.ts
73
+ const archiveCommand = new Command("archive").description("Mark a feature as deprecated (archived)").argument("<key>", "featureKey to archive (e.g. feat-2026-001)").option("-d, --dir <path>", "Directory to scan (default: cwd)").action(async (key, options) => {
74
+ const scanDir = options.dir ?? process$1.cwd();
75
+ const found = (await scanFeatures(scanDir)).find((f) => f.feature.featureKey === key);
76
+ if (!found) {
77
+ process$1.stderr.write(`Error: feature "${key}" not found in "${scanDir}"\n`);
78
+ process$1.exit(1);
79
+ }
80
+ if (found.feature.status === "deprecated") {
81
+ process$1.stdout.write(`Already deprecated: ${key}\n`);
82
+ process$1.exit(0);
83
+ }
84
+ const raw = await readFile(found.filePath, "utf-8");
85
+ const parsed = JSON.parse(raw);
86
+ parsed["status"] = "deprecated";
87
+ const validation = validateFeature(parsed);
88
+ if (!validation.success) {
89
+ process$1.stderr.write(`Validation error: ${validation.errors.join(", ")}\n`);
90
+ process$1.exit(1);
91
+ }
92
+ await writeFile(found.filePath, JSON.stringify(validation.data, null, 2) + "\n", "utf-8");
93
+ process$1.stdout.write(`✓ ${key} archived (status → deprecated)\n`);
94
+ });
95
+
96
+ //#endregion
97
+ //#region src/lib/walker.ts
98
+ /**
99
+ * Walks up the directory tree from startDir to find the nearest feature.json.
100
+ * Returns the absolute path or null if not found before reaching the filesystem root.
101
+ */
102
+ function findNearestFeatureJson$1(startDir) {
103
+ let current = resolve(startDir);
104
+ while (true) {
105
+ const candidate = join(current, "feature.json");
106
+ if (existsSync(candidate)) return candidate;
107
+ const parent = dirname(current);
108
+ if (parent === current) return null;
109
+ current = parent;
110
+ }
111
+ }
112
+ /**
113
+ * Walks up the directory tree from startDir to find the nearest .git directory.
114
+ * Returns the absolute path to .git or null if not found.
115
+ */
116
+ function findGitDir(startDir) {
117
+ let current = resolve(startDir);
118
+ while (true) {
119
+ const candidate = join(current, ".git");
120
+ if (existsSync(candidate)) return candidate;
121
+ const parent = dirname(current);
122
+ if (parent === current) return null;
123
+ current = parent;
124
+ }
125
+ }
126
+ /**
127
+ * Walks up the directory tree from startDir to find the nearest lac.config.json.
128
+ * Returns the absolute path or null if not found.
129
+ */
130
+ function findLacConfig(startDir) {
131
+ let current = resolve(startDir);
132
+ while (true) {
133
+ const candidate = join(current, "lac.config.json");
134
+ if (existsSync(candidate)) return candidate;
135
+ const parent = dirname(current);
136
+ if (parent === current) return null;
137
+ current = parent;
138
+ }
139
+ }
140
+
141
+ //#endregion
142
+ //#region src/lib/config.ts
143
+ const DEFAULTS = {
144
+ version: 1,
145
+ requiredFields: ["problem"],
146
+ ciThreshold: 0,
147
+ lintStatuses: ["active", "draft"],
148
+ domain: "feat"
149
+ };
150
+ function loadConfig(fromDir) {
151
+ const configPath = findLacConfig(fromDir ?? process$1.cwd());
152
+ if (!configPath) return { ...DEFAULTS };
153
+ try {
154
+ const raw = readFileSync(configPath, "utf-8");
155
+ const parsed = JSON.parse(raw);
156
+ return {
157
+ version: parsed.version ?? DEFAULTS.version,
158
+ requiredFields: parsed.requiredFields ?? DEFAULTS.requiredFields,
159
+ ciThreshold: parsed.ciThreshold ?? DEFAULTS.ciThreshold,
160
+ lintStatuses: parsed.lintStatuses ?? DEFAULTS.lintStatuses,
161
+ domain: parsed.domain ?? DEFAULTS.domain
162
+ };
163
+ } catch {
164
+ process$1.stderr.write(`Warning: could not parse lac.config.json at "${configPath}" — using defaults\n`);
165
+ return { ...DEFAULTS };
166
+ }
167
+ }
168
+ /** The 6 optional fields used to compute completeness score (0–100) */
169
+ const OPTIONAL_FIELDS = [
170
+ "analysis",
171
+ "decisions",
172
+ "implementation",
173
+ "knownLimitations",
174
+ "tags",
175
+ "annotations"
176
+ ];
177
+ function computeCompleteness(feature) {
178
+ const filled = OPTIONAL_FIELDS.filter((field) => {
179
+ const val = feature[field];
180
+ if (val === void 0 || val === null || val === "") return false;
181
+ if (Array.isArray(val)) return val.length > 0;
182
+ return typeof val === "string" && val.trim().length > 0;
183
+ }).length;
184
+ return Math.round(filled / OPTIONAL_FIELDS.length * 100);
185
+ }
186
+
187
+ //#endregion
188
+ //#region src/commands/blame.ts
189
+ const blameCommand = new Command("blame").description("Show which feature owns a file or path").argument("<path>", "File path to trace (supports path:line format, line is ignored)").action((rawPath) => {
190
+ const filePath = rawPath.replace(/:\d+$/, "");
191
+ const featureJsonPath = findNearestFeatureJson$1(dirname(resolve(filePath)));
192
+ if (!featureJsonPath) {
193
+ process$1.stderr.write(`No feature.json found for "${filePath}".\nRun "lac init" in the feature folder to create one.\n`);
194
+ process$1.exit(1);
195
+ }
196
+ let raw;
197
+ try {
198
+ raw = readFileSync(featureJsonPath, "utf-8");
199
+ } catch (err) {
200
+ const message = err instanceof Error ? err.message : String(err);
201
+ process$1.stderr.write(`Error reading "${featureJsonPath}": ${message}\n`);
202
+ process$1.exit(1);
203
+ }
204
+ let parsed;
205
+ try {
206
+ parsed = JSON.parse(raw);
207
+ } catch {
208
+ process$1.stderr.write(`Error: "${featureJsonPath}" contains invalid JSON.\n`);
209
+ process$1.exit(1);
210
+ }
211
+ const result = validateFeature(parsed);
212
+ if (!result.success) {
213
+ process$1.stderr.write(`Error: "${featureJsonPath}" failed validation:\n ${result.errors.join("\n ")}\n`);
214
+ process$1.exit(1);
215
+ }
216
+ const f = result.data;
217
+ const icon = {
218
+ active: "⊙",
219
+ draft: "◌",
220
+ frozen: "❄",
221
+ deprecated: "⊘"
222
+ }[f.status] ?? "?";
223
+ const completeness = computeCompleteness(f);
224
+ const bar = "█".repeat(Math.round(completeness / 10)) + "░".repeat(10 - Math.round(completeness / 10));
225
+ process$1.stdout.write("\n");
226
+ process$1.stdout.write(` Feature : ${f.featureKey}\n`);
227
+ process$1.stdout.write(` Title : ${f.title}\n`);
228
+ process$1.stdout.write(` Status : ${icon} ${f.status}\n`);
229
+ process$1.stdout.write(` Complete : [${bar}] ${completeness}%\n`);
230
+ process$1.stdout.write(` Path : ${featureJsonPath}\n`);
231
+ process$1.stdout.write("\n");
232
+ process$1.stdout.write(` Problem:\n ${f.problem}\n`);
233
+ if (f.analysis) {
234
+ const excerpt = f.analysis.length > 120 ? f.analysis.slice(0, 120) + "…" : f.analysis;
235
+ process$1.stdout.write(`\n Analysis:\n ${excerpt}\n`);
236
+ }
237
+ if (f.decisions && f.decisions.length > 0) {
238
+ process$1.stdout.write(`\n Decisions (${f.decisions.length}):\n`);
239
+ for (const d of f.decisions) process$1.stdout.write(` • ${d.decision}\n Rationale: ${d.rationale}\n`);
240
+ }
241
+ if (f.knownLimitations && f.knownLimitations.length > 0) {
242
+ process$1.stdout.write(`\n Known Limitations:\n`);
243
+ for (const lim of f.knownLimitations) process$1.stdout.write(` - ${lim}\n`);
244
+ }
245
+ if (f.lineage?.parent) process$1.stdout.write(`\n Lineage : parent → ${f.lineage.parent}\n`);
246
+ process$1.stdout.write("\n");
247
+ });
248
+
249
+ //#endregion
250
+ //#region src/commands/diff.ts
251
+ /**
252
+ * Stable JSON serialisation that sorts object keys recursively so that two
253
+ * objects with the same content but different insertion order compare equal.
254
+ */
255
+ function stableStringify(val) {
256
+ if (val === null || typeof val !== "object") return JSON.stringify(val);
257
+ if (Array.isArray(val)) return `[${val.map(stableStringify).join(",")}]`;
258
+ return `{${Object.keys(val).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(val[k])}`).join(",")}}`;
259
+ }
260
+ function formatValue(val) {
261
+ if (val === void 0 || val === null) return "(empty)";
262
+ if (typeof val === "string") return val.length > 80 ? val.slice(0, 77) + "..." : val;
263
+ return JSON.stringify(val);
264
+ }
265
+ const diffCommand = new Command("diff").description("Compare two features field-by-field").argument("<key1>", "First featureKey").argument("<key2>", "Second featureKey").option("-d, --dir <path>", "Directory to scan (default: cwd)").action(async (key1, key2, options) => {
266
+ const scanDir = options.dir ?? process$1.cwd();
267
+ let features;
268
+ try {
269
+ features = await scanFeatures(scanDir);
270
+ } catch (err) {
271
+ const message = err instanceof Error ? err.message : String(err);
272
+ process$1.stderr.write(`Error scanning "${scanDir}": ${message}\n`);
273
+ process$1.exit(1);
274
+ }
275
+ const f1 = features.find((f) => f.feature.featureKey === key1);
276
+ const f2 = features.find((f) => f.feature.featureKey === key2);
277
+ if (!f1) {
278
+ process$1.stderr.write(`Error: feature "${key1}" not found in "${scanDir}"\n`);
279
+ process$1.exit(1);
280
+ }
281
+ if (!f2) {
282
+ process$1.stderr.write(`Error: feature "${key2}" not found in "${scanDir}"\n`);
283
+ process$1.exit(1);
284
+ }
285
+ const obj1 = f1.feature;
286
+ const obj2 = f2.feature;
287
+ const allKeys = new Set([...Object.keys(obj1), ...Object.keys(obj2)]);
288
+ const lines = [];
289
+ lines.push(`diff ${key1} → ${key2}`);
290
+ lines.push("─".repeat(60));
291
+ let hasDiffs = false;
292
+ for (const field of allKeys) {
293
+ const v1 = obj1[field];
294
+ const v2 = obj2[field];
295
+ if (stableStringify(v1) === stableStringify(v2)) continue;
296
+ hasDiffs = true;
297
+ if (v1 === void 0) lines.push(`+ ${field}: ${formatValue(v2)}`);
298
+ else if (v2 === void 0) lines.push(`- ${field}: ${formatValue(v1)}`);
299
+ else {
300
+ lines.push(`~ ${field}:`);
301
+ lines.push(` OLD: ${formatValue(v1)}`);
302
+ lines.push(` NEW: ${formatValue(v2)}`);
303
+ }
304
+ }
305
+ if (!hasDiffs) lines.push("(no differences)");
306
+ process$1.stdout.write(lines.join("\n") + "\n");
307
+ });
308
+
309
+ //#endregion
310
+ //#region src/commands/doctor.ts
311
+ /** Mirrors the findLacDir logic from keygen.ts */
312
+ function findLacDir$1(fromDir) {
313
+ let current = path.resolve(fromDir);
314
+ while (true) {
315
+ const candidate = path.join(current, ".lac");
316
+ try {
317
+ if (statSync(candidate).isDirectory()) return candidate;
318
+ } catch {}
319
+ const parent = path.dirname(current);
320
+ if (parent === current) return null;
321
+ current = parent;
322
+ }
323
+ }
324
+ /** Walk a directory tree collecting all feature.json paths with validation info. */
325
+ async function walkFeatureFiles(currentDir) {
326
+ let validCount = 0;
327
+ const invalidFiles = [];
328
+ async function walk(dir) {
329
+ let rawEntries;
330
+ try {
331
+ rawEntries = await readdir(dir, { withFileTypes: true });
332
+ } catch {
333
+ return;
334
+ }
335
+ const entries = rawEntries.map((e) => ({
336
+ name: String(e.name),
337
+ isDirectory: () => e.isDirectory(),
338
+ isFile: () => e.isFile()
339
+ }));
340
+ for (const entry of entries) {
341
+ const fullPath = join(dir, entry.name);
342
+ if (entry.isDirectory()) {
343
+ if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
344
+ await walk(fullPath);
345
+ } else if (entry.isFile() && entry.name === "feature.json") {
346
+ let raw;
347
+ try {
348
+ raw = await readFile(fullPath, "utf-8");
349
+ } catch {
350
+ invalidFiles.push({
351
+ filePath: fullPath,
352
+ errors: ["could not read file"]
353
+ });
354
+ continue;
355
+ }
356
+ let parsed;
357
+ try {
358
+ parsed = JSON.parse(raw);
359
+ } catch {
360
+ invalidFiles.push({
361
+ filePath: fullPath,
362
+ errors: ["invalid JSON"]
363
+ });
364
+ continue;
365
+ }
366
+ const result = validateFeature(parsed);
367
+ if (!result.success) invalidFiles.push({
368
+ filePath: fullPath,
369
+ errors: result.errors
370
+ });
371
+ else validCount++;
372
+ }
373
+ }
374
+ }
375
+ await walk(currentDir);
376
+ return {
377
+ valid: validCount,
378
+ invalid: invalidFiles
379
+ };
380
+ }
381
+ const doctorCommand = new Command("doctor").description("Check workspace health and report any issues").argument("[dir]", "Directory to check (default: cwd)").action(async (dir) => {
382
+ const checkDir = dir ? path.resolve(dir) : process$1.cwd();
383
+ let passed = 0;
384
+ let warned = 0;
385
+ let failed = 0;
386
+ const output = [];
387
+ output.push("lac doctor — workspace diagnostics");
388
+ output.push("===================================");
389
+ output.push("");
390
+ let lacDir = null;
391
+ try {
392
+ lacDir = findLacDir$1(checkDir);
393
+ } catch {}
394
+ if (lacDir) {
395
+ output.push(`✓ Workspace found at ${lacDir}`);
396
+ passed++;
397
+ } else {
398
+ output.push("✗ No .lac/ workspace — run: lac workspace init");
399
+ failed++;
400
+ }
401
+ if (lacDir) {
402
+ const counterPath = join(lacDir, "counter");
403
+ let counterOk = false;
404
+ let counterYear = null;
405
+ let nextKey = "";
406
+ try {
407
+ const parts = readFileSync(counterPath, "utf-8").trim().split("\n").map((l) => l.trim());
408
+ const yr = parseInt(parts[0] ?? "", 10);
409
+ const cnt = parseInt(parts[1] ?? "", 10);
410
+ if (!isNaN(yr) && !isNaN(cnt)) {
411
+ counterOk = true;
412
+ counterYear = yr;
413
+ nextKey = `feat-${yr}-${String(cnt + 1).padStart(3, "0")}`;
414
+ }
415
+ } catch {}
416
+ if (counterOk && counterYear !== null) {
417
+ output.push(`✓ Counter valid — next key preview: ${nextKey}`);
418
+ passed++;
419
+ const currentYear = (/* @__PURE__ */ new Date()).getFullYear();
420
+ if (counterYear !== currentYear) {
421
+ output.push(`⚠ Counter year is stale (${counterYear}) — will reset on next lac init`);
422
+ warned++;
423
+ }
424
+ } else {
425
+ output.push("✗ Counter file missing or corrupt — run: lac workspace init --force");
426
+ failed++;
427
+ }
428
+ }
429
+ try {
430
+ const { valid, invalid } = await walkFeatureFiles(checkDir);
431
+ const total = valid + invalid.length;
432
+ output.push(`✓ Found ${total} feature.json file${total === 1 ? "" : "s"}`);
433
+ passed++;
434
+ for (const inv of invalid) {
435
+ output.push(` ✗ Invalid: ${inv.filePath} — ${inv.errors.join("; ")}`);
436
+ failed++;
437
+ }
438
+ } catch {
439
+ output.push("✗ Could not scan feature.json files");
440
+ failed++;
441
+ }
442
+ try {
443
+ const configPath = findLacConfig(checkDir);
444
+ if (!configPath) output.push(" (no lac.config.json — using defaults)");
445
+ else try {
446
+ const raw = readFileSync(configPath, "utf-8");
447
+ const parsed = JSON.parse(raw);
448
+ const domain = typeof parsed.domain === "string" ? parsed.domain : "feat";
449
+ const threshold = typeof parsed.ciThreshold === "number" ? parsed.ciThreshold : 0;
450
+ output.push(`✓ lac.config.json valid (domain: ${domain}, threshold: ${threshold})`);
451
+ passed++;
452
+ } catch {
453
+ output.push("✗ lac.config.json is invalid JSON — fix or delete it");
454
+ failed++;
455
+ }
456
+ } catch {
457
+ output.push(" (no lac.config.json — using defaults)");
458
+ }
459
+ try {
460
+ const result = spawnSync("lac-lsp", ["--help"], {
461
+ timeout: 2e3,
462
+ stdio: "ignore"
463
+ });
464
+ if (result.error || result.status === null) {
465
+ output.push("✗ lac-lsp not found — install: npm i -g @life-as-code/lac-lsp");
466
+ failed++;
467
+ } else {
468
+ output.push("✓ lac-lsp found in PATH");
469
+ passed++;
470
+ }
471
+ } catch {
472
+ output.push("✗ lac-lsp not found — install: npm i -g @life-as-code/lac-lsp");
473
+ failed++;
474
+ }
475
+ try {
476
+ const config = loadConfig(checkDir);
477
+ const toCheck = (await scanFeatures(checkDir)).filter(({ feature }) => config.lintStatuses.includes(feature.status));
478
+ let lintWarnCount = 0;
479
+ for (const { feature } of toCheck) {
480
+ const raw = feature;
481
+ const completeness = computeCompleteness(raw);
482
+ const missingRequired = config.requiredFields.filter((field) => {
483
+ const val = raw[field];
484
+ if (val === void 0 || val === null || val === "") return true;
485
+ if (Array.isArray(val)) return val.length === 0;
486
+ return typeof val === "string" && val.trim().length === 0;
487
+ });
488
+ const belowThreshold = config.ciThreshold > 0 && completeness < config.ciThreshold;
489
+ if (missingRequired.length > 0 || belowThreshold) lintWarnCount++;
490
+ }
491
+ if (lintWarnCount === 0) {
492
+ output.push("✓ All features pass lint");
493
+ passed++;
494
+ } else {
495
+ output.push(`⚠ ${lintWarnCount} feature${lintWarnCount === 1 ? "" : "s"} have lint warnings`);
496
+ warned++;
497
+ }
498
+ } catch {
499
+ output.push("⚠ Could not run lint check");
500
+ warned++;
501
+ }
502
+ output.push("");
503
+ output.push(`Overall: ${passed} check${passed === 1 ? "" : "s"} passed, ${warned} warning${warned === 1 ? "" : "s"}, ${failed} failure${failed === 1 ? "" : "s"}`);
504
+ if (failed === 0) {
505
+ output.push("");
506
+ output.push("Next steps:");
507
+ output.push(" lac serve → open dashboard at http://127.0.0.1:7474");
508
+ output.push(" lac hooks install → lint on every commit");
509
+ output.push(" lac init → create your first feature");
510
+ }
511
+ process$1.stdout.write(output.join("\n") + "\n");
512
+ process$1.exit(failed > 0 ? 1 : 0);
513
+ });
514
+
515
+ //#endregion
516
+ //#region src/templates/markdown.ts
517
+ /**
518
+ * Minimal markdown → HTML converter for use in the static site generator.
519
+ * Handles the subset of markdown used in feature.json documentation fields:
520
+ * headings, code blocks, inline code, bold, tables, lists, and paragraphs.
521
+ */
522
+ function escapeHtml$2(s) {
523
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
524
+ }
525
+ function inlineMarkdown(text) {
526
+ return escapeHtml$2(text).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\[([^\]]+)\]\(((?:[^()]*|\([^()]*\))*)\)/g, "<a href=\"$2\">$1</a>");
527
+ }
528
+ function isTableRow(line) {
529
+ return line.trim().startsWith("|") && line.trim().endsWith("|");
530
+ }
531
+ function isTableSeparator(line) {
532
+ return /^\|[\s\-:|]+\|/.test(line.trim());
533
+ }
534
+ function renderTableRow(line, isHeader) {
535
+ const cells = line.trim().slice(1, -1).split("|").map((c) => c.trim());
536
+ const tag = isHeader ? "th" : "td";
537
+ return `<tr>${cells.map((c) => `<${tag}>${inlineMarkdown(c)}</${tag}>`).join("")}</tr>`;
538
+ }
539
+ function markdownToHtml(md) {
540
+ const lines = md.split("\n");
541
+ const out = [];
542
+ let i = 0;
543
+ while (i < lines.length) {
544
+ const line = lines[i] ?? "";
545
+ if (line.startsWith("```")) {
546
+ const lang = line.slice(3).trim();
547
+ const codeLines = [];
548
+ i++;
549
+ while (i < lines.length && !(lines[i] ?? "").startsWith("```")) {
550
+ codeLines.push(lines[i] ?? "");
551
+ i++;
552
+ }
553
+ if (i < lines.length) i++;
554
+ const langAttr = lang ? ` class="language-${escapeHtml$2(lang)}"` : "";
555
+ out.push(`<pre><code${langAttr}>${escapeHtml$2(codeLines.join("\n"))}</code></pre>`);
556
+ continue;
557
+ }
558
+ if (line.startsWith("### ")) {
559
+ out.push(`<h3>${inlineMarkdown(line.slice(4))}</h3>`);
560
+ i++;
561
+ continue;
562
+ }
563
+ if (line.startsWith("## ")) {
564
+ out.push(`<h2>${inlineMarkdown(line.slice(3))}</h2>`);
565
+ i++;
566
+ continue;
567
+ }
568
+ if (line.startsWith("# ")) {
569
+ out.push(`<h1>${inlineMarkdown(line.slice(2))}</h1>`);
570
+ i++;
571
+ continue;
572
+ }
573
+ if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
574
+ out.push("<hr />");
575
+ i++;
576
+ continue;
577
+ }
578
+ if (isTableRow(line)) {
579
+ const tableRows = [];
580
+ let firstRow = true;
581
+ while (i < lines.length && isTableRow(lines[i] ?? "")) {
582
+ const current = lines[i] ?? "";
583
+ if (isTableSeparator(current)) {
584
+ i++;
585
+ continue;
586
+ }
587
+ tableRows.push(renderTableRow(current, firstRow));
588
+ if (firstRow) firstRow = false;
589
+ i++;
590
+ }
591
+ out.push(`<table class="md-table"><thead>${tableRows[0] ?? ""}</thead><tbody>${tableRows.slice(1).join("")}</tbody></table>`);
592
+ continue;
593
+ }
594
+ if (/^[-*] /.test(line)) {
595
+ const items = [];
596
+ while (i < lines.length && /^[-*] /.test(lines[i] ?? "")) {
597
+ items.push(`<li>${inlineMarkdown((lines[i] ?? "").slice(2))}</li>`);
598
+ i++;
599
+ }
600
+ out.push(`<ul>${items.join("")}</ul>`);
601
+ continue;
602
+ }
603
+ if (/^[1-9]\d*\. /.test(line)) {
604
+ const items = [];
605
+ while (i < lines.length && /^[1-9]\d*\. /.test(lines[i] ?? "")) {
606
+ items.push(`<li>${inlineMarkdown((lines[i] ?? "").replace(/^[1-9]\d*\. /, ""))}</li>`);
607
+ i++;
608
+ }
609
+ out.push(`<ol>${items.join("")}</ol>`);
610
+ continue;
611
+ }
612
+ if (line.trim() === "") {
613
+ i++;
614
+ continue;
615
+ }
616
+ const paraLines = [];
617
+ while (i < lines.length && (lines[i] ?? "").trim() !== "" && !(lines[i] ?? "").startsWith("#") && !(lines[i] ?? "").startsWith("```") && !/^[-*] /.test(lines[i] ?? "") && !/^[1-9]\d*\. /.test(lines[i] ?? "") && !isTableRow(lines[i] ?? "")) {
618
+ paraLines.push(lines[i] ?? "");
619
+ i++;
620
+ }
621
+ if (paraLines.length > 0) out.push(`<p>${inlineMarkdown(paraLines.join(" "))}</p>`);
622
+ }
623
+ return out.join("\n");
624
+ }
625
+
626
+ //#endregion
627
+ //#region src/templates/site-style.css.ts
628
+ const css = `
629
+ :root {
630
+ --color-bg: #ffffff;
631
+ --color-surface: #f8f9fa;
632
+ --color-border: #dee2e6;
633
+ --color-text: #212529;
634
+ --color-text-muted: #6c757d;
635
+ --color-link: #0d6efd;
636
+ --color-link-hover: #0a58ca;
637
+ --color-active: #198754;
638
+ --color-draft: #6c757d;
639
+ --color-frozen: #0d6efd;
640
+ --color-deprecated: #dc3545;
641
+ }
642
+
643
+ @media (prefers-color-scheme: dark) {
644
+ :root {
645
+ --color-bg: #1a1a2e;
646
+ --color-surface: #16213e;
647
+ --color-border: #374151;
648
+ --color-text: #e9ecef;
649
+ --color-text-muted: #9ca3af;
650
+ --color-link: #60a5fa;
651
+ --color-link-hover: #93c5fd;
652
+ --color-active: #4ade80;
653
+ --color-draft: #9ca3af;
654
+ --color-frozen: #60a5fa;
655
+ --color-deprecated: #f87171;
656
+ }
657
+ }
658
+
659
+ *, *::before, *::after {
660
+ box-sizing: border-box;
661
+ }
662
+
663
+ html {
664
+ font-size: 16px;
665
+ line-height: 1.6;
666
+ }
667
+
668
+ body {
669
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
670
+ Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
671
+ background-color: var(--color-bg);
672
+ color: var(--color-text);
673
+ margin: 0;
674
+ padding: 0;
675
+ }
676
+
677
+ .container {
678
+ max-width: 800px;
679
+ margin: 0 auto;
680
+ padding: 2rem 1rem;
681
+ }
682
+
683
+ h1 {
684
+ font-size: 2rem;
685
+ font-weight: 700;
686
+ margin-top: 0;
687
+ margin-bottom: 0.5rem;
688
+ }
689
+
690
+ h2 {
691
+ font-size: 1.25rem;
692
+ font-weight: 600;
693
+ margin-top: 2rem;
694
+ margin-bottom: 0.75rem;
695
+ border-bottom: 1px solid var(--color-border);
696
+ padding-bottom: 0.25rem;
697
+ }
698
+
699
+ a {
700
+ color: var(--color-link);
701
+ text-decoration: none;
702
+ }
703
+
704
+ a:hover {
705
+ color: var(--color-link-hover);
706
+ text-decoration: underline;
707
+ }
708
+
709
+ p {
710
+ margin: 0 0 1rem;
711
+ }
712
+
713
+ .meta {
714
+ display: flex;
715
+ align-items: center;
716
+ gap: 0.75rem;
717
+ margin-bottom: 1.5rem;
718
+ color: var(--color-text-muted);
719
+ font-size: 0.875rem;
720
+ }
721
+
722
+ .feature-key {
723
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
724
+ 'Liberation Mono', 'Courier New', monospace;
725
+ font-size: 0.875rem;
726
+ color: var(--color-text-muted);
727
+ }
728
+
729
+ /* Status badges */
730
+ .status-badge {
731
+ display: inline-block;
732
+ padding: 0.125rem 0.5rem;
733
+ border-radius: 9999px;
734
+ font-size: 0.75rem;
735
+ font-weight: 600;
736
+ text-transform: uppercase;
737
+ letter-spacing: 0.05em;
738
+ }
739
+
740
+ .status-active {
741
+ color: var(--color-active);
742
+ background-color: color-mix(in srgb, var(--color-active) 15%, transparent);
743
+ border: 1px solid color-mix(in srgb, var(--color-active) 30%, transparent);
744
+ }
745
+
746
+ .status-draft {
747
+ color: var(--color-draft);
748
+ background-color: color-mix(in srgb, var(--color-draft) 15%, transparent);
749
+ border: 1px solid color-mix(in srgb, var(--color-draft) 30%, transparent);
750
+ }
751
+
752
+ .status-frozen {
753
+ color: var(--color-frozen);
754
+ background-color: color-mix(in srgb, var(--color-frozen) 15%, transparent);
755
+ border: 1px solid color-mix(in srgb, var(--color-frozen) 30%, transparent);
756
+ }
757
+
758
+ .status-deprecated {
759
+ color: var(--color-deprecated);
760
+ background-color: color-mix(in srgb, var(--color-deprecated) 15%, transparent);
761
+ border: 1px solid color-mix(in srgb, var(--color-deprecated) 30%, transparent);
762
+ }
763
+
764
+ /* Search */
765
+ .search-wrapper {
766
+ margin-bottom: 1.5rem;
767
+ }
768
+
769
+ .search-input {
770
+ width: 100%;
771
+ padding: 0.5rem 0.75rem;
772
+ border: 1px solid var(--color-border);
773
+ border-radius: 0.375rem;
774
+ background-color: var(--color-surface);
775
+ color: var(--color-text);
776
+ font-size: 1rem;
777
+ font-family: inherit;
778
+ outline: none;
779
+ transition: border-color 0.15s ease;
780
+ }
781
+
782
+ .search-input:focus {
783
+ border-color: var(--color-link);
784
+ }
785
+
786
+ /* Feature table */
787
+ .feature-table {
788
+ width: 100%;
789
+ border-collapse: collapse;
790
+ font-size: 0.9375rem;
791
+ }
792
+
793
+ .feature-table th {
794
+ text-align: left;
795
+ padding: 0.5rem 0.75rem;
796
+ border-bottom: 2px solid var(--color-border);
797
+ color: var(--color-text-muted);
798
+ font-size: 0.75rem;
799
+ font-weight: 600;
800
+ text-transform: uppercase;
801
+ letter-spacing: 0.05em;
802
+ }
803
+
804
+ .feature-table td {
805
+ padding: 0.625rem 0.75rem;
806
+ border-bottom: 1px solid var(--color-border);
807
+ vertical-align: top;
808
+ }
809
+
810
+ .feature-table tr:last-child td {
811
+ border-bottom: none;
812
+ }
813
+
814
+ .feature-table tr:hover td {
815
+ background-color: var(--color-surface);
816
+ }
817
+
818
+ .problem-excerpt {
819
+ color: var(--color-text-muted);
820
+ font-size: 0.875rem;
821
+ }
822
+
823
+ /* Sections */
824
+ section {
825
+ margin-bottom: 2rem;
826
+ }
827
+
828
+ .problem-text {
829
+ background-color: var(--color-surface);
830
+ border-left: 3px solid var(--color-link);
831
+ padding: 1rem 1.25rem;
832
+ border-radius: 0 0.375rem 0.375rem 0;
833
+ margin: 0;
834
+ }
835
+
836
+ /* Decisions timeline */
837
+ ol.decisions {
838
+ list-style: none;
839
+ padding: 0;
840
+ margin: 0;
841
+ }
842
+
843
+ ol.decisions li {
844
+ position: relative;
845
+ padding: 1rem 1rem 1rem 1.5rem;
846
+ border-left: 2px solid var(--color-border);
847
+ margin-bottom: 1rem;
848
+ }
849
+
850
+ ol.decisions li:last-child {
851
+ margin-bottom: 0;
852
+ }
853
+
854
+ ol.decisions li::before {
855
+ content: '';
856
+ position: absolute;
857
+ left: -0.375rem;
858
+ top: 1.25rem;
859
+ width: 0.625rem;
860
+ height: 0.625rem;
861
+ border-radius: 50%;
862
+ background-color: var(--color-link);
863
+ }
864
+
865
+ .decision-date {
866
+ font-size: 0.75rem;
867
+ color: var(--color-text-muted);
868
+ margin-bottom: 0.25rem;
869
+ }
870
+
871
+ .decision-text {
872
+ font-weight: 600;
873
+ margin-bottom: 0.375rem;
874
+ }
875
+
876
+ .decision-rationale {
877
+ color: var(--color-text-muted);
878
+ font-size: 0.9375rem;
879
+ margin-bottom: 0.375rem;
880
+ }
881
+
882
+ .alternatives {
883
+ font-size: 0.875rem;
884
+ color: var(--color-text-muted);
885
+ }
886
+
887
+ .alternatives span {
888
+ font-weight: 500;
889
+ }
890
+
891
+ /* Implementation / limitation sections */
892
+ .implementation-text {
893
+ white-space: pre-wrap;
894
+ font-size: 0.9375rem;
895
+ line-height: 1.7;
896
+ }
897
+
898
+ ul.limitations {
899
+ padding-left: 1.5rem;
900
+ margin: 0;
901
+ }
902
+
903
+ ul.limitations li {
904
+ margin-bottom: 0.375rem;
905
+ color: var(--color-text-muted);
906
+ }
907
+
908
+ /* Lineage */
909
+ .lineage-info {
910
+ background-color: var(--color-surface);
911
+ border: 1px solid var(--color-border);
912
+ border-radius: 0.375rem;
913
+ padding: 1rem 1.25rem;
914
+ }
915
+
916
+ .lineage-info p {
917
+ margin-bottom: 0.5rem;
918
+ }
919
+
920
+ .lineage-info p:last-child {
921
+ margin-bottom: 0;
922
+ }
923
+
924
+ /* Back link */
925
+ .back-link {
926
+ display: inline-flex;
927
+ align-items: center;
928
+ gap: 0.25rem;
929
+ margin-bottom: 2rem;
930
+ font-size: 0.875rem;
931
+ }
932
+
933
+ /* Empty state */
934
+ .empty-state {
935
+ text-align: center;
936
+ padding: 3rem 1rem;
937
+ color: var(--color-text-muted);
938
+ }
939
+
940
+ /* No results row */
941
+ .no-results {
942
+ display: none;
943
+ padding: 1.5rem 0.75rem;
944
+ color: var(--color-text-muted);
945
+ font-style: italic;
946
+ }
947
+
948
+ /* Markdown-rendered content */
949
+ .implementation-text h2,
950
+ .analysis-text h2 {
951
+ font-size: 1.125rem;
952
+ font-weight: 600;
953
+ margin-top: 1.75rem;
954
+ margin-bottom: 0.5rem;
955
+ border-bottom: 1px solid var(--color-border);
956
+ padding-bottom: 0.25rem;
957
+ }
958
+
959
+ .implementation-text h3,
960
+ .analysis-text h3 {
961
+ font-size: 1rem;
962
+ font-weight: 600;
963
+ margin-top: 1.25rem;
964
+ margin-bottom: 0.375rem;
965
+ color: var(--color-text-muted);
966
+ }
967
+
968
+ .implementation-text pre,
969
+ .analysis-text pre {
970
+ background-color: var(--color-surface);
971
+ border: 1px solid var(--color-border);
972
+ border-radius: 0.375rem;
973
+ padding: 1rem 1.25rem;
974
+ overflow-x: auto;
975
+ margin: 1rem 0;
976
+ }
977
+
978
+ .implementation-text code,
979
+ .analysis-text code {
980
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
981
+ font-size: 0.875em;
982
+ }
983
+
984
+ .implementation-text pre code,
985
+ .analysis-text pre code {
986
+ background: none;
987
+ padding: 0;
988
+ border-radius: 0;
989
+ font-size: 0.875rem;
990
+ }
991
+
992
+ .implementation-text :not(pre) > code,
993
+ .analysis-text :not(pre) > code {
994
+ background-color: var(--color-surface);
995
+ border: 1px solid var(--color-border);
996
+ border-radius: 0.25rem;
997
+ padding: 0.1em 0.35em;
998
+ }
999
+
1000
+ .implementation-text ul,
1001
+ .analysis-text ul,
1002
+ .implementation-text ol,
1003
+ .analysis-text ol {
1004
+ padding-left: 1.5rem;
1005
+ margin: 0.5rem 0 1rem;
1006
+ }
1007
+
1008
+ .implementation-text li,
1009
+ .analysis-text li {
1010
+ margin-bottom: 0.25rem;
1011
+ }
1012
+
1013
+ /* Markdown tables */
1014
+ .md-table {
1015
+ width: 100%;
1016
+ border-collapse: collapse;
1017
+ margin: 1rem 0;
1018
+ font-size: 0.9rem;
1019
+ }
1020
+
1021
+ .md-table th {
1022
+ text-align: left;
1023
+ padding: 0.5rem 0.75rem;
1024
+ border-bottom: 2px solid var(--color-border);
1025
+ font-size: 0.75rem;
1026
+ font-weight: 600;
1027
+ text-transform: uppercase;
1028
+ letter-spacing: 0.05em;
1029
+ color: var(--color-text-muted);
1030
+ }
1031
+
1032
+ .md-table td {
1033
+ padding: 0.5rem 0.75rem;
1034
+ border-bottom: 1px solid var(--color-border);
1035
+ vertical-align: top;
1036
+ }
1037
+
1038
+ .md-table tr:last-child td {
1039
+ border-bottom: none;
1040
+ }
1041
+
1042
+ .md-table tr:hover td {
1043
+ background-color: var(--color-surface);
1044
+ }
1045
+
1046
+ /* Analysis section */
1047
+ .analysis-text {
1048
+ font-size: 0.9375rem;
1049
+ line-height: 1.7;
1050
+ }
1051
+ `;
1052
+
1053
+ //#endregion
1054
+ //#region src/templates/site-feature.html.ts
1055
+ function escapeHtml$1(str) {
1056
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1057
+ }
1058
+ function statusBadge$1(status) {
1059
+ return `<span class="status-badge status-${escapeHtml$1(status)}">${escapeHtml$1(status)}</span>`;
1060
+ }
1061
+ function renderDecisions(decisions) {
1062
+ if (decisions.length === 0) return "";
1063
+ return `
1064
+ <section class="decisions">
1065
+ <h2>Decisions</h2>
1066
+ <ol class="decisions">
1067
+ ${decisions.map((d) => {
1068
+ const date = d.date ? `<div class="decision-date">${escapeHtml$1(d.date)}</div>` : "";
1069
+ const alts = d.alternativesConsidered && d.alternativesConsidered.length > 0 ? `<div class="alternatives"><span>Alternatives considered:</span> ${d.alternativesConsidered.map(escapeHtml$1).join(", ")}</div>` : "";
1070
+ return `
1071
+ <li>
1072
+ ${date}
1073
+ <div class="decision-text">${escapeHtml$1(d.decision)}</div>
1074
+ <div class="decision-rationale">${escapeHtml$1(d.rationale)}</div>
1075
+ ${alts}
1076
+ </li>`;
1077
+ }).join("\n")}
1078
+ </ol>
1079
+ </section>`;
1080
+ }
1081
+ function renderLineage(lineage) {
1082
+ const parts = [];
1083
+ if (lineage.parent) parts.push(`<p><strong>Parent:</strong> <a href="${escapeHtml$1(lineage.parent)}.html">${escapeHtml$1(lineage.parent)}</a></p>`);
1084
+ if (lineage.children && lineage.children.length > 0) {
1085
+ const childLinks = lineage.children.map((c) => `<a href="${escapeHtml$1(c)}.html">${escapeHtml$1(c)}</a>`).join(", ");
1086
+ parts.push(`<p><strong>Children:</strong> ${childLinks}</p>`);
1087
+ }
1088
+ if (lineage.spawnReason) parts.push(`<p><strong>Spawn reason:</strong> ${escapeHtml$1(lineage.spawnReason)}</p>`);
1089
+ if (parts.length === 0) return "";
1090
+ return `
1091
+ <section class="lineage">
1092
+ <h2>Lineage</h2>
1093
+ <div class="lineage-info">
1094
+ ${parts.join("\n ")}
1095
+ </div>
1096
+ </section>`;
1097
+ }
1098
+ function renderFeature(feature) {
1099
+ const decisionsSection = feature.decisions && feature.decisions.length > 0 ? renderDecisions(feature.decisions) : "";
1100
+ const implementationSection = feature.implementation ? `
1101
+ <section class="implementation">
1102
+ <h2>How it works</h2>
1103
+ <div class="implementation-text">${markdownToHtml(feature.implementation)}</div>
1104
+ </section>` : "";
1105
+ const analysisSection = feature["analysis"] ? `
1106
+ <section class="analysis">
1107
+ <h2>Background &amp; Context</h2>
1108
+ <div class="analysis-text">${markdownToHtml(feature["analysis"])}</div>
1109
+ </section>` : "";
1110
+ const limitationsSection = feature.knownLimitations && feature.knownLimitations.length > 0 ? `
1111
+ <section class="limitations">
1112
+ <h2>Known Limitations</h2>
1113
+ <ul class="limitations">
1114
+ ${feature.knownLimitations.map((l) => `<li>${escapeHtml$1(l)}</li>`).join("\n ")}
1115
+ </ul>
1116
+ </section>` : "";
1117
+ const lineageSection = feature.lineage && (feature.lineage.parent || feature.lineage.children && feature.lineage.children.length > 0 || feature.lineage.spawnReason) ? renderLineage(feature.lineage) : "";
1118
+ const tagsSection = feature.tags && feature.tags.length > 0 ? `<div class="meta" style="margin-top:0.5rem;flex-wrap:wrap">${feature.tags.map((t) => `<span style="font-size:0.75rem;padding:0.125rem 0.5rem;border-radius:9999px;background-color:var(--color-surface);border:1px solid var(--color-border)">${escapeHtml$1(t)}</span>`).join("")}</div>` : "";
1119
+ return `<!DOCTYPE html>
1120
+ <html lang="en">
1121
+ <head>
1122
+ <meta charset="UTF-8" />
1123
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1124
+ <title>${escapeHtml$1(feature.title)} — Feature Provenance</title>
1125
+ <style>${css}</style>
1126
+ </head>
1127
+ <body>
1128
+ <div class="container">
1129
+ <a href="index.html" class="back-link">&#8592; All features</a>
1130
+
1131
+ <h1>${escapeHtml$1(feature.title)}</h1>
1132
+ <div class="meta">
1133
+ ${statusBadge$1(feature.status)}
1134
+ <span class="feature-key">${escapeHtml$1(feature.featureKey)}</span>
1135
+ </div>
1136
+ ${tagsSection}
1137
+
1138
+ <section class="problem">
1139
+ <h2>Problem</h2>
1140
+ <p class="problem-text">${escapeHtml$1(feature.problem)}</p>
1141
+ </section>
1142
+
1143
+ ${analysisSection}
1144
+ ${decisionsSection}
1145
+ ${implementationSection}
1146
+ ${limitationsSection}
1147
+ ${lineageSection}
1148
+ </div>
1149
+ </body>
1150
+ </html>`;
1151
+ }
1152
+
1153
+ //#endregion
1154
+ //#region src/templates/site-index.html.ts
1155
+ function escapeHtml(str) {
1156
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1157
+ }
1158
+ function statusBadge(status) {
1159
+ return `<span class="status-badge status-${escapeHtml(status)}">${escapeHtml(status)}</span>`;
1160
+ }
1161
+ function renderIndex(features, generatedAt) {
1162
+ const timestamp = (generatedAt ?? /* @__PURE__ */ new Date()).toISOString();
1163
+ const rows = features.length === 0 ? `<tr><td colspan="4" class="no-results" style="display:table-cell">No features found.</td></tr>` : features.map((f) => `
1164
+ <tr class="feature-row" data-search="${escapeHtml((f.featureKey + " " + f.title).toLowerCase())}">
1165
+ <td><a href="${escapeHtml(f.featureKey)}.html" class="feature-key">${escapeHtml(f.featureKey)}</a></td>
1166
+ <td><a href="${escapeHtml(f.featureKey)}.html">${escapeHtml(f.title)}</a></td>
1167
+ <td>${statusBadge(f.status)}</td>
1168
+ <td class="problem-excerpt">${escapeHtml(f.problem.slice(0, 100))}${f.problem.length > 100 ? "…" : ""}</td>
1169
+ </tr>`).join("\n");
1170
+ return `<!DOCTYPE html>
1171
+ <html lang="en">
1172
+ <head>
1173
+ <meta charset="UTF-8" />
1174
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1175
+ <title>Feature Provenance</title>
1176
+ <style>${css}</style>
1177
+ </head>
1178
+ <body>
1179
+ <div class="container">
1180
+ <h1>Feature Provenance</h1>
1181
+ <p style="color:var(--color-text-muted);margin-bottom:1.5rem">${features.length} feature${features.length === 1 ? "" : "s"} tracked</p>
1182
+
1183
+ <div class="search-wrapper">
1184
+ <input
1185
+ type="search"
1186
+ id="search"
1187
+ class="search-input"
1188
+ placeholder="Search by key or title…"
1189
+ aria-label="Search features"
1190
+ />
1191
+ </div>
1192
+
1193
+ <table class="feature-table" id="feature-table">
1194
+ <thead>
1195
+ <tr>
1196
+ <th>Key</th>
1197
+ <th>Title</th>
1198
+ <th>Status</th>
1199
+ <th>Problem</th>
1200
+ </tr>
1201
+ </thead>
1202
+ <tbody id="feature-tbody">
1203
+ ${rows}
1204
+ <tr id="no-results-row" style="display:none">
1205
+ <td colspan="4" class="no-results" style="display:table-cell;color:var(--color-text-muted);font-style:italic;padding:1.5rem 0.75rem">No features match your search.</td>
1206
+ </tr>
1207
+ </tbody>
1208
+ </table>
1209
+ </div>
1210
+
1211
+ <footer style="margin-top:2rem;padding-top:1rem;border-top:1px solid var(--color-border);color:var(--color-text-muted);font-size:0.75rem;text-align:right">
1212
+ Generated ${escapeHtml(timestamp)}
1213
+ </footer>
1214
+
1215
+ <script>
1216
+ (function () {
1217
+ var input = document.getElementById('search');
1218
+ var noResults = document.getElementById('no-results-row');
1219
+ if (!input) return;
1220
+ input.addEventListener('input', function () {
1221
+ var query = input.value.trim().toLowerCase();
1222
+ var rows = document.querySelectorAll('#feature-tbody .feature-row');
1223
+ var visible = 0;
1224
+ rows.forEach(function (row) {
1225
+ var search = row.getAttribute('data-search') || '';
1226
+ var match = query === '' || search.indexOf(query) !== -1;
1227
+ row.style.display = match ? '' : 'none';
1228
+ if (match) visible++;
1229
+ });
1230
+ if (noResults) {
1231
+ noResults.style.display = (visible === 0 && query !== '') ? '' : 'none';
1232
+ }
1233
+ });
1234
+ })();
1235
+ <\/script>
1236
+ </body>
1237
+ </html>`;
1238
+ }
1239
+
1240
+ //#endregion
1241
+ //#region src/lib/siteGenerator.ts
1242
+ /**
1243
+ * Generates a static HTML site for the given features.
1244
+ * Creates:
1245
+ * outDir/index.html — searchable feature list
1246
+ * outDir/{featureKey}.html — one page per feature
1247
+ * outDir/style.css — shared stylesheet
1248
+ */
1249
+ async function generateSite(features, outDir) {
1250
+ await mkdir(outDir, { recursive: true });
1251
+ await writeFile(join(outDir, "style.css"), css.trim(), "utf-8");
1252
+ const allFeatures = features.map((f) => f.feature);
1253
+ await writeFile(join(outDir, "index.html"), renderIndex(allFeatures), "utf-8");
1254
+ for (const { feature } of features) {
1255
+ const pageHtml = renderFeature(feature);
1256
+ await writeFile(join(outDir, `${feature.featureKey}.html`), pageHtml, "utf-8");
1257
+ }
1258
+ }
1259
+
1260
+ //#endregion
1261
+ //#region src/commands/export.ts
1262
+ /**
1263
+ * Walks up the directory tree from `startDir` to find the nearest feature.json.
1264
+ * Returns the absolute path or null if not found.
1265
+ */
1266
+ function findNearestFeatureJson(startDir) {
1267
+ let current = resolve(startDir);
1268
+ while (true) {
1269
+ const candidate = join(current, "feature.json");
1270
+ if (existsSync(candidate)) return candidate;
1271
+ const parent = dirname(current);
1272
+ if (parent === current) return null;
1273
+ current = parent;
1274
+ }
1275
+ }
1276
+ /** Render a feature as a Markdown document */
1277
+ function featureToMarkdown(feature) {
1278
+ const f = feature;
1279
+ const lines = [];
1280
+ lines.push(`# ${f["title"]}`);
1281
+ lines.push("");
1282
+ lines.push(`**Key:** \`${f["featureKey"]}\` `);
1283
+ lines.push(`**Status:** ${f["status"]}`);
1284
+ lines.push("");
1285
+ if (f["problem"]) {
1286
+ lines.push("## Problem");
1287
+ lines.push("");
1288
+ lines.push(f["problem"]);
1289
+ lines.push("");
1290
+ }
1291
+ if (f["analysis"]) {
1292
+ lines.push("## Analysis");
1293
+ lines.push("");
1294
+ lines.push(f["analysis"]);
1295
+ lines.push("");
1296
+ }
1297
+ if (f["implementation"]) {
1298
+ lines.push("## Implementation");
1299
+ lines.push("");
1300
+ lines.push(f["implementation"]);
1301
+ lines.push("");
1302
+ }
1303
+ const limitations = f["knownLimitations"];
1304
+ if (limitations && limitations.length > 0) {
1305
+ lines.push("## Known Limitations");
1306
+ lines.push("");
1307
+ for (const lim of limitations) lines.push(`- ${lim}`);
1308
+ lines.push("");
1309
+ }
1310
+ const decisions = f["decisions"];
1311
+ if (decisions && decisions.length > 0) {
1312
+ lines.push("## Decisions");
1313
+ lines.push("");
1314
+ for (const d of decisions) {
1315
+ lines.push(`### ${d["decision"]}`);
1316
+ lines.push("");
1317
+ lines.push(`**Rationale:** ${d["rationale"]}`);
1318
+ if (d["date"]) lines.push(`**Date:** ${d["date"]}`);
1319
+ lines.push("");
1320
+ }
1321
+ }
1322
+ const annotations = f["annotations"];
1323
+ if (annotations && annotations.length > 0) {
1324
+ lines.push("## Annotations");
1325
+ lines.push("");
1326
+ for (const a of annotations) lines.push(`- **[${a["type"]}]** ${a["body"]} _(${a["author"]}, ${a["date"]})_`);
1327
+ lines.push("");
1328
+ }
1329
+ const tags = f["tags"];
1330
+ if (tags && tags.length > 0) {
1331
+ lines.push(`**Tags:** ${tags.join(", ")}`);
1332
+ lines.push("");
1333
+ }
1334
+ return lines.join("\n");
1335
+ }
1336
+ const exportCommand = new Command("export").description("Export feature.json as JSON, Markdown, or generate a static HTML site").option("--out <path>", "Output file or directory path").option("--site <dir>", "Scan <dir> for feature.json files and generate a static HTML site").option("--markdown", "Output feature as a Markdown document instead of JSON").action(async (options) => {
1337
+ if (options.site !== void 0) {
1338
+ const scanDir = resolve(options.site);
1339
+ const outDir = resolve(options.out ?? "./lac-site");
1340
+ let features;
1341
+ try {
1342
+ features = await scanFeatures(scanDir);
1343
+ } catch (err) {
1344
+ const message = err instanceof Error ? err.message : String(err);
1345
+ process$1.stderr.write(`Error scanning "${scanDir}": ${message}\n`);
1346
+ process$1.exit(1);
1347
+ }
1348
+ if (features.length === 0) {
1349
+ process$1.stdout.write(`No valid feature.json files found in "${scanDir}".\n`);
1350
+ process$1.exit(0);
1351
+ }
1352
+ try {
1353
+ await generateSite(features, outDir);
1354
+ } catch (err) {
1355
+ const message = err instanceof Error ? err.message : String(err);
1356
+ process$1.stderr.write(`Error generating site: ${message}\n`);
1357
+ process$1.exit(1);
1358
+ }
1359
+ const displayOut = options.out ?? "./lac-site";
1360
+ process$1.stdout.write(`✓ Generated ${features.length} page${features.length === 1 ? "" : "s"} → ${displayOut}\n`);
1361
+ return;
1362
+ }
1363
+ const featureJsonPath = findNearestFeatureJson(process$1.cwd());
1364
+ if (!featureJsonPath) {
1365
+ process$1.stderr.write(`Error: no feature.json found in the current directory or any of its parents.\n`);
1366
+ process$1.exit(1);
1367
+ }
1368
+ let raw;
1369
+ try {
1370
+ raw = await readFile(featureJsonPath, "utf-8");
1371
+ } catch (err) {
1372
+ const message = err instanceof Error ? err.message : String(err);
1373
+ process$1.stderr.write(`Error reading "${featureJsonPath}": ${message}\n`);
1374
+ process$1.exit(1);
1375
+ }
1376
+ let parsed;
1377
+ try {
1378
+ parsed = JSON.parse(raw);
1379
+ } catch {
1380
+ process$1.stderr.write(`Error: "${featureJsonPath}" contains invalid JSON.\n`);
1381
+ process$1.exit(1);
1382
+ }
1383
+ const result = validateFeature(parsed);
1384
+ if (!result.success) {
1385
+ process$1.stderr.write(`Error: "${featureJsonPath}" failed validation:\n ${result.errors.join("\n ")}\n`);
1386
+ process$1.exit(1);
1387
+ }
1388
+ if (options.markdown) {
1389
+ const mdOutput = featureToMarkdown(result.data);
1390
+ if (options.out) {
1391
+ const outPath = resolve(options.out);
1392
+ try {
1393
+ await writeFile(outPath, mdOutput, "utf-8");
1394
+ process$1.stdout.write(`Exported to ${outPath}\n`);
1395
+ } catch (err) {
1396
+ const message = err instanceof Error ? err.message : String(err);
1397
+ process$1.stderr.write(`Error writing to "${outPath}": ${message}\n`);
1398
+ process$1.exit(1);
1399
+ }
1400
+ } else process$1.stdout.write(mdOutput);
1401
+ return;
1402
+ }
1403
+ const output = JSON.stringify(result.data, null, 2) + "\n";
1404
+ if (options.out) {
1405
+ const outPath = resolve(options.out);
1406
+ try {
1407
+ await writeFile(outPath, output, "utf-8");
1408
+ process$1.stdout.write(`Exported to ${outPath}\n`);
1409
+ } catch (err) {
1410
+ const message = err instanceof Error ? err.message : String(err);
1411
+ process$1.stderr.write(`Error writing to "${outPath}": ${message}\n`);
1412
+ process$1.exit(1);
1413
+ }
1414
+ } else process$1.stdout.write(output);
1415
+ });
1416
+
1417
+ //#endregion
1418
+ //#region src/commands/hooks.ts
1419
+ const LAC_MARKER = "# managed-by-lac";
1420
+ const HOOK_SCRIPT = `#!/bin/sh
1421
+ ${LAC_MARKER}
1422
+ # Provenance lint — runs lac lint before every commit.
1423
+ # To remove: run "lac hooks uninstall"
1424
+
1425
+ # Find lac in PATH, or try npx as fallback
1426
+ if command -v lac >/dev/null 2>&1; then
1427
+ LAC_CMD="lac"
1428
+ else
1429
+ LAC_CMD="npx --yes lac"
1430
+ fi
1431
+
1432
+ if ! $LAC_CMD lint --quiet 2>/tmp/lac-lint-output; then
1433
+ echo ""
1434
+ echo "lac pre-commit: lint failed — commit blocked"
1435
+ # Show failing feature keys from lint output
1436
+ if [ -s /tmp/lac-lint-output ]; then
1437
+ grep -o 'feat-[a-z0-9]*-[0-9]*\\|[a-z][a-z0-9]*-[0-9]\\{4\\}-[0-9]*' /tmp/lac-lint-output | sort -u | while read key; do
1438
+ echo " ✗ $key"
1439
+ done
1440
+ fi
1441
+ echo ""
1442
+ echo " Run \\"lac lint\\" for details."
1443
+ echo ""
1444
+ rm -f /tmp/lac-lint-output
1445
+ exit 1
1446
+ fi
1447
+ rm -f /tmp/lac-lint-output
1448
+ `;
1449
+ const hooksCommand = new Command("hooks").description("Manage git hooks for provenance linting");
1450
+ hooksCommand.command("install").description("Install a pre-commit hook that runs \"lac lint\" before each commit").option("--force", "Overwrite existing pre-commit hook even if not managed by lac").action((options) => {
1451
+ const gitDir = findGitDir(process$1.cwd());
1452
+ if (!gitDir) {
1453
+ process$1.stderr.write(`Error: no .git directory found. Are you inside a git repository?\n`);
1454
+ process$1.exit(1);
1455
+ }
1456
+ const hooksDir = join(gitDir, "hooks");
1457
+ const hookPath = join(hooksDir, "pre-commit");
1458
+ if (!existsSync(hooksDir)) mkdirSync(hooksDir, { recursive: true });
1459
+ if (existsSync(hookPath) && !options.force) {
1460
+ if (!readFileSync(hookPath, "utf-8").includes(LAC_MARKER)) {
1461
+ process$1.stderr.write(`Error: a pre-commit hook already exists at "${hookPath}" and was not installed by lac.\nUse --force to overwrite it.\n`);
1462
+ process$1.exit(1);
1463
+ }
1464
+ }
1465
+ writeFileSync(hookPath, HOOK_SCRIPT, "utf-8");
1466
+ chmodSync(hookPath, 493);
1467
+ process$1.stdout.write(`✓ Installed pre-commit hook at ${hookPath}\n`);
1468
+ process$1.stdout.write(` "lac lint" will run before every commit.\n`);
1469
+ process$1.stdout.write(` To remove: run "lac hooks uninstall"\n`);
1470
+ });
1471
+ hooksCommand.command("uninstall").description("Remove the lac-managed pre-commit hook").action(() => {
1472
+ const gitDir = findGitDir(process$1.cwd());
1473
+ if (!gitDir) {
1474
+ process$1.stderr.write(`Error: no .git directory found.\n`);
1475
+ process$1.exit(1);
1476
+ }
1477
+ const hookPath = join(gitDir, "hooks", "pre-commit");
1478
+ if (!existsSync(hookPath)) {
1479
+ process$1.stdout.write(`No pre-commit hook found at "${hookPath}".\n`);
1480
+ return;
1481
+ }
1482
+ if (!readFileSync(hookPath, "utf-8").includes(LAC_MARKER)) {
1483
+ process$1.stderr.write(`Error: the pre-commit hook at "${hookPath}" was not installed by lac.\nRemove it manually if you want to uninstall it.\n`);
1484
+ process$1.exit(1);
1485
+ }
1486
+ rmSync(hookPath);
1487
+ process$1.stdout.write(`✓ Removed lac pre-commit hook from ${hookPath}\n`);
1488
+ });
1489
+ hooksCommand.command("status").description("Show whether the lac pre-commit hook is installed").action(() => {
1490
+ const gitDir = findGitDir(process$1.cwd());
1491
+ if (!gitDir) {
1492
+ process$1.stdout.write(`Not inside a git repository.\n`);
1493
+ return;
1494
+ }
1495
+ const hookPath = join(gitDir, "hooks", "pre-commit");
1496
+ if (!existsSync(hookPath)) {
1497
+ process$1.stdout.write(`pre-commit hook: not installed\n`);
1498
+ return;
1499
+ }
1500
+ if (readFileSync(hookPath, "utf-8").includes(LAC_MARKER)) process$1.stdout.write(`pre-commit hook: ✓ installed (managed by lac)\n`);
1501
+ else process$1.stdout.write(`pre-commit hook: installed (NOT managed by lac — foreign hook)\n`);
1502
+ });
1503
+
1504
+ //#endregion
1505
+ //#region src/lib/featureKey.ts
1506
+ /**
1507
+ * Generates a new featureKey for the given directory, honouring the `domain`
1508
+ * field from the nearest `lac.config.json`.
1509
+ *
1510
+ * @throws {Error} If no `.lac/` directory can be found in fromDir or its parents.
1511
+ */
1512
+ function nextFeatureKey(fromDir) {
1513
+ return generateFeatureKey(fromDir, loadConfig(fromDir).domain);
1514
+ }
1515
+
1516
+ //#endregion
1517
+ //#region src/commands/init.ts
1518
+ /**
1519
+ * Derives a short title from a problem statement by taking the first 6 words
1520
+ * and appending "..." when truncated.
1521
+ */
1522
+ function titleFromProblem$1(problem) {
1523
+ const words = problem.trim().split(/\s+/);
1524
+ if (words.length <= 6) return problem.trim();
1525
+ return words.slice(0, 6).join(" ") + "...";
1526
+ }
1527
+ const initCommand = new Command("init").description("Scaffold a feature.json in the current directory").option("-f, --force", "Overwrite existing feature.json", false).action(async (options) => {
1528
+ const cwd = process$1.cwd();
1529
+ const featureJsonPath = join(cwd, "feature.json");
1530
+ if (existsSync(featureJsonPath) && !options.force) {
1531
+ process$1.stderr.write(`Error: feature.json already exists in this directory.\nUse --force to overwrite.\n`);
1532
+ process$1.exit(1);
1533
+ }
1534
+ const answers = await prompts([{
1535
+ type: "text",
1536
+ name: "problem",
1537
+ message: "What problem does this feature solve?",
1538
+ initial: "e.g. Users cannot reset their password without contacting support",
1539
+ validate: (value) => value.trim().length > 0 ? true : "Problem statement is required"
1540
+ }, {
1541
+ type: "select",
1542
+ name: "status",
1543
+ message: "Status?",
1544
+ choices: [{
1545
+ title: "draft",
1546
+ value: "draft"
1547
+ }, {
1548
+ title: "active",
1549
+ value: "active"
1550
+ }],
1551
+ initial: 0
1552
+ }], { onCancel: () => {
1553
+ process$1.stderr.write("Aborted.\n");
1554
+ process$1.exit(1);
1555
+ } });
1556
+ const problem = answers.problem.trim();
1557
+ const status = answers.status;
1558
+ let featureKey;
1559
+ try {
1560
+ featureKey = nextFeatureKey(cwd);
1561
+ } catch (err) {
1562
+ const message = err instanceof Error ? err.message : String(err);
1563
+ process$1.stderr.write(`Error: ${message}\n\nTip: run "lac workspace init" first to create the .lac/ workspace.\n`);
1564
+ process$1.exit(1);
1565
+ }
1566
+ const title = titleFromProblem$1(problem);
1567
+ const wasTruncated = problem.trim().split(/\s+/).length > 6;
1568
+ let finalTitle = title;
1569
+ if (wasTruncated) {
1570
+ process$1.stdout.write(`\nTitle will be: "${title}"\n`);
1571
+ const titleAnswer = await prompts({
1572
+ type: "text",
1573
+ name: "customTitle",
1574
+ message: "Custom title? (leave blank to use the above)",
1575
+ initial: ""
1576
+ });
1577
+ if (titleAnswer.customTitle && titleAnswer.customTitle.trim().length > 0) finalTitle = titleAnswer.customTitle.trim();
1578
+ }
1579
+ const validation = validateFeature({
1580
+ featureKey,
1581
+ title: finalTitle,
1582
+ status,
1583
+ problem
1584
+ });
1585
+ if (!validation.success) {
1586
+ process$1.stderr.write(`Internal error: generated feature did not pass validation:\n ${validation.errors.join("\n ")}\n`);
1587
+ process$1.exit(1);
1588
+ }
1589
+ await writeFile(featureJsonPath, JSON.stringify(validation.data, null, 2) + "\n", "utf-8");
1590
+ process$1.stdout.write(`✓ Created feature.json — ${featureKey}\n`);
1591
+ });
1592
+
1593
+ //#endregion
1594
+ //#region src/commands/lineage.ts
1595
+ /**
1596
+ * Build a lineage tree rooted at the given featureKey.
1597
+ * Children are features that list this key as their lineage.parent.
1598
+ */
1599
+ function buildTree(key, byKey, childrenOf, visited = /* @__PURE__ */ new Set()) {
1600
+ visited.add(key);
1601
+ const feature = byKey.get(key);
1602
+ const childKeys = childrenOf.get(key) ?? [];
1603
+ const children = [];
1604
+ for (const ck of childKeys) if (!visited.has(ck)) children.push(buildTree(ck, byKey, childrenOf, visited));
1605
+ return {
1606
+ key,
1607
+ status: feature?.status ?? "unknown",
1608
+ title: feature?.title ?? "(unknown)",
1609
+ children
1610
+ };
1611
+ }
1612
+ /**
1613
+ * Render a FeatureNode tree as ASCII art.
1614
+ */
1615
+ function renderTree(node, prefix = "", isLast = true) {
1616
+ const lines = [`${prefix}${prefix === "" ? "" : isLast ? "└── " : "├── "}${node.key} (${node.status}) — ${node.title}`];
1617
+ const childPrefix = prefix + (isLast ? " " : "│ ");
1618
+ for (let i = 0; i < node.children.length; i++) {
1619
+ const child = node.children[i];
1620
+ if (child) {
1621
+ const childLines = renderTree(child, childPrefix, i === node.children.length - 1);
1622
+ lines.push(...childLines);
1623
+ }
1624
+ }
1625
+ return lines;
1626
+ }
1627
+ const lineageCommand = new Command("lineage").description("Show the lineage tree (parent → key → children) for a feature").argument("<key>", "featureKey to inspect (e.g. feat-2026-001)").option("-d, --dir <path>", "Directory to scan (default: cwd)").action(async (key, options) => {
1628
+ const scanDir = options.dir ?? process$1.cwd();
1629
+ let features;
1630
+ try {
1631
+ features = await scanFeatures(scanDir);
1632
+ } catch (err) {
1633
+ const message = err instanceof Error ? err.message : String(err);
1634
+ process$1.stderr.write(`Error scanning "${scanDir}": ${message}\n`);
1635
+ process$1.exit(1);
1636
+ }
1637
+ const byKey = /* @__PURE__ */ new Map();
1638
+ const childrenOf = /* @__PURE__ */ new Map();
1639
+ for (const { feature } of features) {
1640
+ byKey.set(feature.featureKey, feature);
1641
+ const parent = feature.lineage?.parent;
1642
+ if (parent) {
1643
+ const existing = childrenOf.get(parent) ?? [];
1644
+ existing.push(feature.featureKey);
1645
+ childrenOf.set(parent, existing);
1646
+ }
1647
+ }
1648
+ if (!byKey.has(key)) {
1649
+ process$1.stderr.write(`Error: feature "${key}" not found in "${scanDir}"\n`);
1650
+ process$1.exit(1);
1651
+ }
1652
+ let rootKey = key;
1653
+ const seen = /* @__PURE__ */ new Set();
1654
+ while (true) {
1655
+ seen.add(rootKey);
1656
+ const parent = byKey.get(rootKey)?.lineage?.parent;
1657
+ if (!parent || !byKey.has(parent) || seen.has(parent)) break;
1658
+ rootKey = parent;
1659
+ }
1660
+ const lines = renderTree(buildTree(rootKey, byKey, childrenOf));
1661
+ process$1.stdout.write(lines.join("\n") + "\n");
1662
+ });
1663
+
1664
+ //#endregion
1665
+ //#region src/commands/lint.ts
1666
+ function checkFeature(feature, filePath, requiredFields, threshold) {
1667
+ const raw = feature;
1668
+ const completeness = computeCompleteness(raw);
1669
+ const missingRequired = requiredFields.filter((field) => {
1670
+ const val = raw[field];
1671
+ if (val === void 0 || val === null || val === "") return true;
1672
+ if (Array.isArray(val)) return val.length === 0;
1673
+ return typeof val === "string" && val.trim().length === 0;
1674
+ });
1675
+ const belowThreshold = threshold > 0 && completeness < threshold;
1676
+ return {
1677
+ featureKey: feature.featureKey,
1678
+ filePath,
1679
+ status: feature.status,
1680
+ completeness,
1681
+ missingRequired,
1682
+ belowThreshold,
1683
+ pass: missingRequired.length === 0 && !belowThreshold
1684
+ };
1685
+ }
1686
+ /** Default placeholder values for auto-fix of missing required fields */
1687
+ const FIELD_DEFAULTS = {
1688
+ problem: "TODO: describe the problem this feature solves.",
1689
+ analysis: "",
1690
+ decisions: [],
1691
+ implementation: "",
1692
+ knownLimitations: [],
1693
+ tags: []
1694
+ };
1695
+ /**
1696
+ * Auto-repair a feature file by inserting default values for missing required fields.
1697
+ * Returns the number of fields fixed, or 0 if nothing was changed.
1698
+ */
1699
+ async function fixFeature(filePath, missingFields) {
1700
+ if (missingFields.length === 0) return 0;
1701
+ let raw;
1702
+ try {
1703
+ raw = await readFile(filePath, "utf-8");
1704
+ } catch {
1705
+ return 0;
1706
+ }
1707
+ let parsed;
1708
+ try {
1709
+ parsed = JSON.parse(raw);
1710
+ } catch {
1711
+ return 0;
1712
+ }
1713
+ let fixed = 0;
1714
+ for (const field of missingFields) if (field in FIELD_DEFAULTS) {
1715
+ parsed[field] = FIELD_DEFAULTS[field];
1716
+ fixed++;
1717
+ }
1718
+ if (fixed === 0) return 0;
1719
+ const validation = validateFeature(parsed);
1720
+ if (!validation.success) return 0;
1721
+ await writeFile(filePath, JSON.stringify(validation.data, null, 2) + "\n", "utf-8");
1722
+ return fixed;
1723
+ }
1724
+ const lintCommand = new Command("lint").description("Check feature.json files for completeness and required fields").argument("[dir]", "Directory to scan (default: current directory)").option("--require <fields>", "Comma-separated required fields (overrides lac.config.json)").option("--threshold <n>", "Minimum completeness % required (overrides lac.config.json)", parseInt).option("--quiet", "Only print failures, suppress passing results").option("--json", "Output results as JSON").option("--watch", "Re-run lint on every feature.json change").option("--fix", "Auto-insert default values for missing required fields").action(async (dir, options) => {
1725
+ const scanDir = resolve(dir ?? process$1.cwd());
1726
+ const config = loadConfig(scanDir);
1727
+ const requiredFields = options.require ? options.require.split(",").map((f) => f.trim()).filter(Boolean) : config.requiredFields;
1728
+ const threshold = options.threshold !== void 0 ? options.threshold : config.ciThreshold;
1729
+ async function runLint() {
1730
+ let scanned;
1731
+ try {
1732
+ scanned = await scanFeatures(scanDir);
1733
+ } catch (err) {
1734
+ const message = err instanceof Error ? err.message : String(err);
1735
+ process$1.stderr.write(`Error scanning "${scanDir}": ${message}\n`);
1736
+ return 1;
1737
+ }
1738
+ if (scanned.length === 0) {
1739
+ process$1.stdout.write(`No feature.json files found in "${scanDir}".\n`);
1740
+ return 0;
1741
+ }
1742
+ const results = scanned.filter(({ feature }) => config.lintStatuses.includes(feature.status)).map(({ feature, filePath }) => checkFeature(feature, filePath, requiredFields, threshold));
1743
+ if (options.fix) {
1744
+ const toFix = results.filter((r) => !r.pass && r.missingRequired.length > 0);
1745
+ let totalFixed = 0;
1746
+ for (const r of toFix) {
1747
+ const count = await fixFeature(r.filePath, r.missingRequired);
1748
+ if (count > 0) {
1749
+ totalFixed += count;
1750
+ process$1.stdout.write(` 🔧 ${r.featureKey} fixed ${count} field${count === 1 ? "" : "s"} (${r.missingRequired.join(", ")})\n`);
1751
+ }
1752
+ }
1753
+ if (totalFixed === 0 && toFix.length > 0) {
1754
+ process$1.stdout.write(` No fields could be auto-fixed (fields may not have default values).\n`);
1755
+ return 1;
1756
+ }
1757
+ if (toFix.length === 0) {
1758
+ process$1.stdout.write(` Nothing to fix — all required fields present.\n`);
1759
+ return 0;
1760
+ }
1761
+ let rescanned;
1762
+ try {
1763
+ rescanned = await scanFeatures(scanDir);
1764
+ } catch {
1765
+ process$1.stdout.write(`\n✓ Fixed ${totalFixed} field${totalFixed === 1 ? "" : "s"}. Could not re-validate — run "lac lint" to confirm.\n`);
1766
+ return 0;
1767
+ }
1768
+ const stillFailing = rescanned.filter(({ feature }) => config.lintStatuses.includes(feature.status)).map(({ feature, filePath }) => checkFeature(feature, filePath, requiredFields, threshold)).filter((r) => !r.pass);
1769
+ if (stillFailing.length === 0) {
1770
+ process$1.stdout.write(`\n✓ Fixed ${totalFixed} field${totalFixed === 1 ? "" : "s"} — all features now pass lint.\n`);
1771
+ return 0;
1772
+ }
1773
+ process$1.stdout.write(`\n⚠ Fixed ${totalFixed} field${totalFixed === 1 ? "" : "s"} but ${stillFailing.length} feature${stillFailing.length === 1 ? "" : "s"} still fail lint:\n`);
1774
+ for (const r of stillFailing) process$1.stdout.write(` ✗ ${r.featureKey} missing: ${r.missingRequired.join(", ")}\n`);
1775
+ return 1;
1776
+ }
1777
+ const failures = results.filter((r) => !r.pass);
1778
+ const passes = results.filter((r) => r.pass);
1779
+ if (options.json) {
1780
+ process$1.stdout.write(JSON.stringify({
1781
+ results,
1782
+ failures: failures.length,
1783
+ passes: passes.length
1784
+ }, null, 2) + "\n");
1785
+ return failures.length > 0 ? 1 : 0;
1786
+ }
1787
+ const col = (s, width) => s.slice(0, width).padEnd(width);
1788
+ if (!options.quiet || passes.length > 0) {
1789
+ if (!options.quiet) for (const r of passes) process$1.stdout.write(` ✓ ${col(r.featureKey, 18)} ${r.completeness.toString().padStart(3)}% ${r.status}\n`);
1790
+ }
1791
+ for (const r of failures) {
1792
+ process$1.stdout.write(` ✗ ${col(r.featureKey, 18)} ${r.completeness.toString().padStart(3)}% ${r.status}\n`);
1793
+ for (const field of r.missingRequired) process$1.stdout.write(` missing required field: ${field}\n`);
1794
+ if (r.belowThreshold) process$1.stdout.write(` completeness ${r.completeness}% is below threshold ${threshold}%\n`);
1795
+ }
1796
+ process$1.stdout.write(`\n${passes.length} passed, ${failures.length} failed — ${results.length} features checked\n`);
1797
+ if (failures.length > 0) {
1798
+ if (!options.quiet) {
1799
+ process$1.stdout.write(`\nFailing features:\n`);
1800
+ for (const r of failures) process$1.stdout.write(` ${r.featureKey} → ${r.filePath}\n`);
1801
+ }
1802
+ return 1;
1803
+ }
1804
+ return 0;
1805
+ }
1806
+ if (options.watch) {
1807
+ process$1.stdout.write(`Watching "${scanDir}"...\n\n`);
1808
+ await runLint();
1809
+ let debounce = null;
1810
+ fs.watch(scanDir, { recursive: true }, (_event, filename) => {
1811
+ if (!filename || !filename.toString().endsWith("feature.json")) return;
1812
+ if (debounce) clearTimeout(debounce);
1813
+ debounce = setTimeout(async () => {
1814
+ process$1.stdout.write("\x1Bc");
1815
+ await runLint();
1816
+ process$1.stdout.write("\nWatching for changes...\n");
1817
+ }, 300);
1818
+ });
1819
+ process$1.stdin.resume();
1820
+ process$1.on("SIGINT", () => {
1821
+ process$1.stdout.write("\nStopping watch.\n");
1822
+ process$1.exit(0);
1823
+ });
1824
+ } else {
1825
+ const exitCode = await runLint();
1826
+ process$1.exit(exitCode);
1827
+ }
1828
+ });
1829
+
1830
+ //#endregion
1831
+ //#region src/commands/import.ts
1832
+ const importCommand = new Command("import").description("Import features from a JSON file (array of feature objects)").argument("<file>", "Path to a JSON file containing an array of feature objects").option("-d, --dir <path>", "Directory to create feature subdirectories in (default: cwd)").option("--dry-run", "Preview what would be created without writing files").option("--skip-invalid", "Skip invalid features instead of aborting").action(async (file, options) => {
1833
+ const filePath = resolve(file);
1834
+ const outDir = resolve(options.dir ?? process$1.cwd());
1835
+ const dryRun = options.dryRun ?? false;
1836
+ const skipInvalid = options.skipInvalid ?? false;
1837
+ let raw;
1838
+ try {
1839
+ raw = await readFile(filePath, "utf-8");
1840
+ } catch (err) {
1841
+ const message = err instanceof Error ? err.message : String(err);
1842
+ process$1.stderr.write(`Error reading "${filePath}": ${message}\n`);
1843
+ process$1.exit(1);
1844
+ }
1845
+ let parsed;
1846
+ try {
1847
+ parsed = JSON.parse(raw);
1848
+ } catch {
1849
+ process$1.stderr.write(`Error: "${filePath}" contains invalid JSON.\n`);
1850
+ process$1.exit(1);
1851
+ }
1852
+ if (!Array.isArray(parsed)) {
1853
+ process$1.stderr.write(`Error: expected a JSON array of feature objects, got ${typeof parsed}.\n`);
1854
+ process$1.exit(1);
1855
+ }
1856
+ const features = parsed;
1857
+ if (features.length === 0) {
1858
+ process$1.stdout.write(`No features in "${filePath}" — nothing to import.\n`);
1859
+ process$1.exit(0);
1860
+ }
1861
+ process$1.stdout.write(`Found ${features.length} feature${features.length === 1 ? "" : "s"} in "${file}".\n`);
1862
+ if (dryRun) process$1.stdout.write(`Dry run — no files will be written.\n\n`);
1863
+ let imported = 0;
1864
+ let skipped = 0;
1865
+ for (const item of features) {
1866
+ const result = validateFeature(item);
1867
+ if (!result.success) {
1868
+ const key = item?.["featureKey"] ?? "(unknown)";
1869
+ if (skipInvalid) {
1870
+ process$1.stderr.write(` ⚠ ${key} — skipped (invalid): ${result.errors.join(", ")}\n`);
1871
+ skipped++;
1872
+ continue;
1873
+ } else {
1874
+ process$1.stderr.write(` ✗ ${key} — validation failed:\n` + result.errors.map((e) => ` ${e}`).join("\n") + "\n\nAbort. Use --skip-invalid to continue past errors.\n");
1875
+ process$1.exit(1);
1876
+ }
1877
+ }
1878
+ const feature = result.data;
1879
+ const featureDirName = feature.featureKey;
1880
+ const featureDir = join(outDir, featureDirName);
1881
+ const featureFilePath = join(featureDir, "feature.json");
1882
+ if (existsSync(featureDir) && !statSync(featureDir).isDirectory()) {
1883
+ process$1.stderr.write(` ✗ ${feature.featureKey} — path "${featureDirName}" already exists as a file, not a directory.\n`);
1884
+ process$1.exit(1);
1885
+ }
1886
+ const alreadyExists = existsSync(featureFilePath);
1887
+ if (dryRun) {
1888
+ process$1.stdout.write(` Would ${alreadyExists ? "overwrite" : "create"}: ${featureDirName}/feature.json\n`);
1889
+ imported++;
1890
+ continue;
1891
+ }
1892
+ if (alreadyExists) process$1.stderr.write(` ⚠ ${feature.featureKey} — ${featureDirName}/feature.json already exists, overwriting\n`);
1893
+ try {
1894
+ await mkdir(featureDir, { recursive: true });
1895
+ await writeFile(featureFilePath, JSON.stringify(feature, null, 2) + "\n", "utf-8");
1896
+ process$1.stdout.write(` ✓ ${feature.featureKey} → ${featureDirName}/feature.json\n`);
1897
+ imported++;
1898
+ } catch (err) {
1899
+ const message = err instanceof Error ? err.message : String(err);
1900
+ process$1.stderr.write(` ✗ ${feature.featureKey} — write failed: ${message}\n`);
1901
+ process$1.exit(1);
1902
+ }
1903
+ }
1904
+ process$1.stdout.write(`\n`);
1905
+ if (dryRun) process$1.stdout.write(`Would import ${imported} feature${imported === 1 ? "" : "s"}.\n`);
1906
+ else {
1907
+ const parts = [];
1908
+ if (imported > 0) parts.push(`${imported} imported`);
1909
+ if (skipped > 0) parts.push(`${skipped} skipped`);
1910
+ process$1.stdout.write(`Done: ${parts.join(", ")}.\n`);
1911
+ }
1912
+ });
1913
+
1914
+ //#endregion
1915
+ //#region src/commands/rename.ts
1916
+ /**
1917
+ * Patches all featureJson files that reference oldKey in their lineage.parent
1918
+ * or lineage.children to use newKey instead.
1919
+ */
1920
+ async function patchLineageRefs(features, oldKey, newKey) {
1921
+ let patched = 0;
1922
+ for (const { feature, filePath } of features) {
1923
+ let changed = false;
1924
+ const data = { ...feature };
1925
+ const lineage = data["lineage"];
1926
+ if (!lineage) continue;
1927
+ const updatedLineage = { ...lineage };
1928
+ if (updatedLineage.parent === oldKey) {
1929
+ updatedLineage.parent = newKey;
1930
+ changed = true;
1931
+ }
1932
+ if (Array.isArray(updatedLineage.children) && updatedLineage.children.includes(oldKey)) {
1933
+ updatedLineage.children = updatedLineage.children.map((c) => c === oldKey ? newKey : c);
1934
+ changed = true;
1935
+ }
1936
+ if (changed) {
1937
+ data["lineage"] = updatedLineage;
1938
+ const validation = validateFeature(data);
1939
+ if (validation.success) {
1940
+ await writeFile(filePath, JSON.stringify(validation.data, null, 2) + "\n", "utf-8");
1941
+ patched++;
1942
+ }
1943
+ }
1944
+ }
1945
+ return patched;
1946
+ }
1947
+ const renameCommand = new Command("rename").description("Rename a featureKey — updates the feature.json and patches all lineage references").argument("<old-key>", "Current featureKey (e.g. feat-2026-001)").argument("<new-key>", "New featureKey (e.g. feat-2026-099)").option("-d, --dir <path>", "Directory to scan for features (default: cwd)").option("--dry-run", "Preview changes without writing files").action(async (oldKey, newKey, options) => {
1948
+ if (!FEATURE_KEY_PATTERN.test(newKey)) {
1949
+ process$1.stderr.write(`Error: "${newKey}" is not a valid featureKey.\nKeys must match the pattern <domain>-YYYY-NNN (e.g. feat-2026-099).\n`);
1950
+ process$1.exit(1);
1951
+ }
1952
+ if (oldKey === newKey) {
1953
+ process$1.stderr.write(`Error: old and new keys are identical ("${oldKey}").\n`);
1954
+ process$1.exit(1);
1955
+ }
1956
+ const scanDir = options.dir ?? process$1.cwd();
1957
+ const dryRun = options.dryRun ?? false;
1958
+ let features;
1959
+ try {
1960
+ features = await scanFeatures(scanDir);
1961
+ } catch (err) {
1962
+ const message = err instanceof Error ? err.message : String(err);
1963
+ process$1.stderr.write(`Error scanning "${scanDir}": ${message}\n`);
1964
+ process$1.exit(1);
1965
+ }
1966
+ const target = features.find((f) => f.feature.featureKey === oldKey);
1967
+ if (!target) {
1968
+ process$1.stderr.write(`Error: feature "${oldKey}" not found in "${scanDir}".\n`);
1969
+ process$1.exit(1);
1970
+ }
1971
+ const conflict = features.find((f) => f.feature.featureKey === newKey);
1972
+ if (conflict) {
1973
+ process$1.stderr.write(`Error: "${newKey}" is already used by "${path.relative(scanDir, conflict.filePath)}".\n`);
1974
+ process$1.exit(1);
1975
+ }
1976
+ const lineageRefs = features.filter(({ feature }) => {
1977
+ const lineage = feature.lineage;
1978
+ return lineage?.parent === oldKey || Array.isArray(lineage?.children) && lineage.children.includes(oldKey);
1979
+ });
1980
+ if (dryRun) {
1981
+ process$1.stdout.write(`Dry run — no files will be written.\n\n`);
1982
+ process$1.stdout.write(`Would rename: ${oldKey} → ${newKey}\n`);
1983
+ process$1.stdout.write(` File: ${path.relative(scanDir, target.filePath)}\n`);
1984
+ if (lineageRefs.length > 0) {
1985
+ process$1.stdout.write(`\nWould patch ${lineageRefs.length} lineage reference(s):\n`);
1986
+ for (const ref of lineageRefs) process$1.stdout.write(` ${path.relative(scanDir, ref.filePath)}\n`);
1987
+ }
1988
+ return;
1989
+ }
1990
+ const raw = await readFile(target.filePath, "utf-8");
1991
+ const parsed = JSON.parse(raw);
1992
+ parsed["featureKey"] = newKey;
1993
+ const validation = validateFeature(parsed);
1994
+ if (!validation.success) {
1995
+ process$1.stderr.write(`Internal error: renamed feature failed validation:\n ${validation.errors.join("\n ")}\n`);
1996
+ process$1.exit(1);
1997
+ }
1998
+ await writeFile(target.filePath, JSON.stringify(validation.data, null, 2) + "\n", "utf-8");
1999
+ process$1.stdout.write(`✓ Renamed ${oldKey} → ${newKey}\n`);
2000
+ const patched = await patchLineageRefs(features, oldKey, newKey);
2001
+ if (patched > 0) process$1.stdout.write(`✓ Patched ${patched} lineage reference${patched === 1 ? "" : "s"}\n`);
2002
+ let lacDir = null;
2003
+ let cur = path.resolve(scanDir);
2004
+ while (true) {
2005
+ const candidate = path.join(cur, ".lac");
2006
+ if (existsSync(candidate)) {
2007
+ lacDir = candidate;
2008
+ break;
2009
+ }
2010
+ const parent = path.dirname(cur);
2011
+ if (parent === cur) break;
2012
+ cur = parent;
2013
+ }
2014
+ if (lacDir) {
2015
+ const keysPath = path.join(lacDir, "keys");
2016
+ if (existsSync(keysPath)) {
2017
+ const updated = readFileSync(keysPath, "utf-8").trim().split("\n").filter(Boolean).map((k) => k === oldKey ? newKey : k);
2018
+ if (!updated.includes(newKey)) updated.push(newKey);
2019
+ writeFileSync(keysPath, updated.join("\n") + "\n", "utf-8");
2020
+ process$1.stdout.write(`✓ Updated .lac/keys registry\n`);
2021
+ }
2022
+ }
2023
+ });
2024
+
2025
+ //#endregion
2026
+ //#region src/commands/search.ts
2027
+ const searchCommand = new Command("search").description("Search features by keyword across key, title, problem, tags, and analysis").argument("<query>", "Search query (case-insensitive)").option("-d, --dir <path>", "Directory to scan (default: cwd)").option("--json", "Output results as JSON").option("--field <fields>", "Comma-separated fields to search (default: all)").action(async (query, options) => {
2028
+ const features = await scanFeatures(options.dir ?? process$1.cwd());
2029
+ const searchFields = options.field ? options.field.split(",").map((f) => f.trim()) : [
2030
+ "featureKey",
2031
+ "title",
2032
+ "problem",
2033
+ "tags",
2034
+ "analysis",
2035
+ "implementation"
2036
+ ];
2037
+ const q = query.toLowerCase();
2038
+ const matches = features.filter(({ feature }) => {
2039
+ for (const field of searchFields) {
2040
+ const val = feature[field];
2041
+ if (val === void 0 || val === null) continue;
2042
+ if (typeof val === "string" && val.toLowerCase().includes(q)) return true;
2043
+ if (Array.isArray(val) && val.some((v) => typeof v === "string" && v.toLowerCase().includes(q))) return true;
2044
+ }
2045
+ return false;
2046
+ });
2047
+ if (options.json) {
2048
+ process$1.stdout.write(JSON.stringify(matches.map((m) => m.feature), null, 2) + "\n");
2049
+ return;
2050
+ }
2051
+ if (matches.length === 0) {
2052
+ process$1.stdout.write(`No features found matching "${query}"\n`);
2053
+ return;
2054
+ }
2055
+ process$1.stdout.write(`Found ${matches.length} feature(s) matching "${query}":\n\n`);
2056
+ for (const { feature, filePath } of matches) {
2057
+ const statusIcon = {
2058
+ active: "⊙",
2059
+ draft: "◌",
2060
+ frozen: "❄",
2061
+ deprecated: "⊘"
2062
+ }[feature.status] ?? "?";
2063
+ process$1.stdout.write(` ${statusIcon} ${feature.featureKey.padEnd(18)} ${feature.title}\n`);
2064
+ process$1.stdout.write(` ${feature.problem.slice(0, 80)}${feature.problem.length > 80 ? "..." : ""}\n`);
2065
+ process$1.stdout.write(` ${filePath}\n\n`);
2066
+ }
2067
+ });
2068
+
2069
+ //#endregion
2070
+ //#region src/commands/serve.ts
2071
+ function openBrowser(url) {
2072
+ if (process$1.platform === "darwin") spawn("open", [url], {
2073
+ detached: true,
2074
+ stdio: "ignore"
2075
+ }).unref();
2076
+ else if (process$1.platform === "win32") spawn("cmd", [
2077
+ "/c",
2078
+ "start",
2079
+ "",
2080
+ url
2081
+ ], {
2082
+ detached: true,
2083
+ stdio: "ignore"
2084
+ }).unref();
2085
+ else spawn("xdg-open", [url], {
2086
+ detached: true,
2087
+ stdio: "ignore"
2088
+ }).unref();
2089
+ }
2090
+ function waitForServer(port, timeoutMs) {
2091
+ const deadline = Date.now() + timeoutMs;
2092
+ return new Promise((res) => {
2093
+ function attempt() {
2094
+ const req = http.get(`http://127.0.0.1:${port}/health`, (response) => {
2095
+ res(response.statusCode === 200);
2096
+ });
2097
+ req.on("error", () => {
2098
+ if (Date.now() < deadline) setTimeout(attempt, 250);
2099
+ else res(false);
2100
+ });
2101
+ req.end();
2102
+ }
2103
+ attempt();
2104
+ });
2105
+ }
2106
+ function spawnServer(workspaceDir, port) {
2107
+ return spawn("lac-lsp", [
2108
+ "--http-only",
2109
+ "--workspace",
2110
+ workspaceDir,
2111
+ "--port",
2112
+ String(port)
2113
+ ], { stdio: [
2114
+ "ignore",
2115
+ "inherit",
2116
+ "inherit"
2117
+ ] });
2118
+ }
2119
+ const serveCommand = new Command("serve").description("Start the life-as-code HTTP server and open the dashboard in your browser").argument("[dir]", "Workspace root to index (default: current directory)").option("-p, --port <n>", "HTTP port (default: 7474)", "7474").option("--no-open", "Skip opening the browser automatically").action(async (dir, options) => {
2120
+ const workspaceDir = resolve(dir ?? process$1.cwd());
2121
+ const port = parseInt(options.port, 10);
2122
+ const url = `http://127.0.0.1:${port}`;
2123
+ process$1.stdout.write(`Starting lac-lsp HTTP server for workspace "${workspaceDir}" on port ${port}...\n`);
2124
+ let child = spawnServer(workspaceDir, port);
2125
+ let shuttingDown = false;
2126
+ child.on("error", (err) => {
2127
+ process$1.stderr.write(`Error: could not start lac-lsp — ${err.message}\n`);
2128
+ process$1.stderr.write(`Make sure lac-lsp is installed: npm i -g @life-as-code/lac-lsp\n`);
2129
+ process$1.exit(1);
2130
+ });
2131
+ child.on("exit", (code) => {
2132
+ if (!shuttingDown) process$1.stderr.write(`lac-lsp exited with code ${code ?? 0}\n`);
2133
+ });
2134
+ if (await waitForServer(port, 6e3)) {
2135
+ process$1.stdout.write(`\nReady — ${url}\n\n`);
2136
+ process$1.stdout.write(` GET ${url}/features all indexed features\n`);
2137
+ process$1.stdout.write(` GET ${url}/lint run lint against all features\n`);
2138
+ process$1.stdout.write(` GET ${url}/events SSE stream of changes\n\n`);
2139
+ process$1.stdout.write(`Press Ctrl+C to stop.\n`);
2140
+ if (options.open) openBrowser(url);
2141
+ } else process$1.stderr.write(`Warning: server on port ${port} did not respond within 6 s — it may still be starting up.\n`);
2142
+ let failCount = 0;
2143
+ const healthInterval = setInterval(async () => {
2144
+ if (shuttingDown) {
2145
+ clearInterval(healthInterval);
2146
+ return;
2147
+ }
2148
+ if (await waitForServer(port, 2e3)) {
2149
+ failCount = 0;
2150
+ return;
2151
+ }
2152
+ failCount++;
2153
+ if (failCount >= 3) {
2154
+ process$1.stderr.write(`\nWarning: lac-lsp appears to have crashed. Restarting...\n`);
2155
+ try {
2156
+ child.kill();
2157
+ } catch {}
2158
+ child = spawnServer(workspaceDir, port);
2159
+ failCount = 0;
2160
+ child.on("error", (err) => {
2161
+ process$1.stderr.write(`Error restarting lac-lsp — ${err.message}\n`);
2162
+ });
2163
+ if (await waitForServer(port, 6e3)) {
2164
+ process$1.stdout.write(`lac-lsp restarted successfully.\n`);
2165
+ if (options.open) openBrowser(url);
2166
+ } else process$1.stderr.write(`Warning: lac-lsp did not come back up after restart.\n`);
2167
+ }
2168
+ }, 15e3);
2169
+ process$1.on("SIGINT", () => {
2170
+ shuttingDown = true;
2171
+ clearInterval(healthInterval);
2172
+ process$1.stdout.write("\nShutting down...\n");
2173
+ child.kill("SIGTERM");
2174
+ process$1.exit(0);
2175
+ });
2176
+ });
2177
+
2178
+ //#endregion
2179
+ //#region src/commands/spawn.ts
2180
+ const LAC_DIR$1 = ".lac";
2181
+ /**
2182
+ * Walk up from startDir looking for a .lac/ directory.
2183
+ * Returns the absolute path to the workspace root (the dir containing .lac/) or null.
2184
+ */
2185
+ function findWorkspaceRoot(startDir) {
2186
+ let current = resolve(startDir);
2187
+ while (true) {
2188
+ if (existsSync(join(current, LAC_DIR$1))) return current;
2189
+ const parent = dirname(current);
2190
+ if (parent === current) return null;
2191
+ current = parent;
2192
+ }
2193
+ }
2194
+ /**
2195
+ * Derives a short title from a problem statement by taking the first 6 words
2196
+ * and appending "..." when truncated.
2197
+ */
2198
+ function titleFromProblem(problem) {
2199
+ const words = problem.trim().split(/\s+/);
2200
+ if (words.length <= 6) return problem.trim();
2201
+ return words.slice(0, 6).join(" ") + "...";
2202
+ }
2203
+ /**
2204
+ * Slugifies the first 3 words of a string into kebab-case for use as a
2205
+ * default subdirectory name.
2206
+ */
2207
+ function slugifyProblem(problem) {
2208
+ return problem.trim().split(/\s+/).slice(0, 3).join("-").toLowerCase().replace(/[^a-z0-9-]/g, "");
2209
+ }
2210
+ const spawnCommand = new Command("spawn").description("Spawn a child feature from an existing parent feature").argument("<parent-key>", "featureKey of the parent feature (e.g. feat-2026-001)").option("--reason <text>", "Reason for spawning (default: empty)").option("--dir <name>", "Subdirectory name under parent dir (default: slug of problem)").action(async (parentKey, options) => {
2211
+ const workspaceRoot = findWorkspaceRoot(process$1.cwd());
2212
+ if (!workspaceRoot) {
2213
+ process$1.stderr.write("Error: No .lac/ workspace found in current directory or any parent.\nRun \"lac workspace init\" to create one.\n");
2214
+ process$1.exit(1);
2215
+ }
2216
+ let scanned;
2217
+ try {
2218
+ scanned = await scanFeatures(workspaceRoot);
2219
+ } catch (err) {
2220
+ const message = err instanceof Error ? err.message : String(err);
2221
+ process$1.stderr.write(`Error scanning workspace "${workspaceRoot}": ${message}\n`);
2222
+ process$1.exit(1);
2223
+ }
2224
+ const parentEntry = scanned.find((s) => s.feature.featureKey === parentKey);
2225
+ if (!parentEntry) {
2226
+ process$1.stderr.write(`Error: parent feature "${parentKey}" not found in workspace.\nRun "lac blame" or check your .lac/keys file to see available feature keys.\n`);
2227
+ process$1.exit(1);
2228
+ }
2229
+ const parentDir = dirname(parentEntry.filePath);
2230
+ const answers = await prompts([{
2231
+ type: "text",
2232
+ name: "problem",
2233
+ message: "What problem does this new feature solve?",
2234
+ validate: (value) => value.trim().length > 0 ? true : "Problem statement is required"
2235
+ }, {
2236
+ type: "select",
2237
+ name: "status",
2238
+ message: "Status?",
2239
+ choices: [{
2240
+ title: "draft",
2241
+ value: "draft"
2242
+ }, {
2243
+ title: "active",
2244
+ value: "active"
2245
+ }],
2246
+ initial: 0
2247
+ }], { onCancel: () => {
2248
+ process$1.stderr.write("Aborted.\n");
2249
+ process$1.exit(1);
2250
+ } });
2251
+ const reason = options.reason ?? "";
2252
+ const problem = answers.problem.trim();
2253
+ const status = answers.status;
2254
+ let subdirName;
2255
+ if (options.dir) subdirName = options.dir;
2256
+ else {
2257
+ const baseSlug = slugifyProblem(problem);
2258
+ subdirName = baseSlug;
2259
+ let suffix = 1;
2260
+ while (existsSync(join(parentDir, subdirName))) {
2261
+ suffix++;
2262
+ subdirName = `${baseSlug}-${suffix}`;
2263
+ }
2264
+ }
2265
+ const newFeatureDir = join(parentDir, subdirName);
2266
+ await mkdir(newFeatureDir, { recursive: true });
2267
+ let featureKey;
2268
+ try {
2269
+ featureKey = nextFeatureKey(newFeatureDir);
2270
+ } catch (err) {
2271
+ const message = err instanceof Error ? err.message : String(err);
2272
+ process$1.stderr.write(`Error: ${message}\n\nTip: run "lac workspace init" first to create the .lac/ workspace.\n`);
2273
+ process$1.exit(1);
2274
+ }
2275
+ const title = titleFromProblem(problem);
2276
+ const validation = validateFeature({
2277
+ featureKey,
2278
+ title,
2279
+ status,
2280
+ problem,
2281
+ lineage: {
2282
+ parent: parentKey,
2283
+ ...reason ? { spawnReason: reason } : {}
2284
+ }
2285
+ });
2286
+ if (!validation.success) {
2287
+ process$1.stderr.write(`Internal error: generated feature did not pass validation:\n ${validation.errors.join("\n ")}\n`);
2288
+ process$1.exit(1);
2289
+ }
2290
+ await writeFile(join(newFeatureDir, "feature.json"), JSON.stringify(validation.data, null, 2) + "\n", "utf-8");
2291
+ process$1.stdout.write(`✓ Spawned ${featureKey} from ${parentKey} in ${newFeatureDir}\n`);
2292
+ });
2293
+
2294
+ //#endregion
2295
+ //#region src/commands/stat.ts
2296
+ const statCommand = new Command("stat").description("Show workspace statistics: feature counts, status breakdown, completeness, top tags").option("-d, --dir <path>", "Directory to scan (default: cwd)").action(async (options) => {
2297
+ const scanDir = options.dir ?? process$1.cwd();
2298
+ let features;
2299
+ try {
2300
+ features = await scanFeatures(scanDir);
2301
+ } catch (err) {
2302
+ const message = err instanceof Error ? err.message : String(err);
2303
+ process$1.stderr.write(`Error scanning "${scanDir}": ${message}\n`);
2304
+ process$1.exit(1);
2305
+ }
2306
+ const total = features.length;
2307
+ if (total === 0) {
2308
+ process$1.stdout.write(`No features found in "${scanDir}".\n`);
2309
+ process$1.stdout.write(`Run "lac init" in a subdirectory to create your first feature.\n`);
2310
+ return;
2311
+ }
2312
+ const statusBreakdown = {
2313
+ active: 0,
2314
+ draft: 0,
2315
+ frozen: 0,
2316
+ deprecated: 0
2317
+ };
2318
+ for (const { feature } of features) {
2319
+ const s = feature.status;
2320
+ statusBreakdown[s] = (statusBreakdown[s] ?? 0) + 1;
2321
+ }
2322
+ const completenessValues = features.map(({ feature }) => computeCompleteness(feature));
2323
+ const avgCompleteness = completenessValues.length > 0 ? Math.round(completenessValues.reduce((a, b) => a + b, 0) / completenessValues.length) : 0;
2324
+ const zeroDecisions = features.filter(({ feature }) => !feature.decisions || feature.decisions.length === 0).length;
2325
+ const zeroTags = features.filter(({ feature }) => !feature.tags || feature.tags.length === 0).length;
2326
+ const tagCounts = /* @__PURE__ */ new Map();
2327
+ for (const { feature } of features) for (const tag of feature.tags ?? []) tagCounts.set(tag, (tagCounts.get(tag) ?? 0) + 1);
2328
+ const topTags = Array.from(tagCounts.entries()).sort((a, b) => b[1] - a[1]).slice(0, 5);
2329
+ const lines = [];
2330
+ lines.push("lac stat — workspace statistics");
2331
+ lines.push("================================");
2332
+ lines.push("");
2333
+ lines.push(`Total features : ${total}`);
2334
+ lines.push("");
2335
+ lines.push("By status:");
2336
+ for (const [status, count] of Object.entries(statusBreakdown)) if (count > 0) lines.push(` ${status.padEnd(12)}: ${count}`);
2337
+ lines.push("");
2338
+ lines.push(`Avg completeness : ${avgCompleteness}%`);
2339
+ lines.push(`No decisions : ${zeroDecisions}`);
2340
+ lines.push(`No tags : ${zeroTags}`);
2341
+ if (topTags.length > 0) {
2342
+ lines.push("");
2343
+ lines.push("Top 5 tags:");
2344
+ for (const [tag, count] of topTags) lines.push(` ${tag.padEnd(20)}: ${count}`);
2345
+ }
2346
+ process$1.stdout.write(lines.join("\n") + "\n");
2347
+ });
2348
+
2349
+ //#endregion
2350
+ //#region src/commands/tag.ts
2351
+ const tagCommand = new Command("tag").description("Add or remove tags on a feature").argument("<key>", "featureKey to tag (e.g. feat-2026-001)").argument("<tags>", "Comma-separated tags to add (prefix with - to remove, e.g. \"auth,-legacy,api\")").option("-d, --dir <path>", "Directory to scan for features (default: cwd)").action(async (key, tags, options) => {
2352
+ const scanDir = options.dir ?? process$1.cwd();
2353
+ const found = (await scanFeatures(scanDir)).find((f) => f.feature.featureKey === key);
2354
+ if (!found) {
2355
+ process$1.stderr.write(`Error: feature "${key}" not found in "${scanDir}"\n`);
2356
+ process$1.exit(1);
2357
+ }
2358
+ const tagList = tags.split(",").map((t) => t.trim()).filter(Boolean);
2359
+ const toAdd = tagList.filter((t) => !t.startsWith("-"));
2360
+ const toRemove = tagList.filter((t) => t.startsWith("-")).map((t) => t.slice(1));
2361
+ const current = found.feature.tags ?? [];
2362
+ for (const tag of toAdd) if (current.includes(tag)) process$1.stdout.write(`Note: tag "${tag}" already present\n`);
2363
+ for (const tag of toRemove) if (!current.includes(tag)) process$1.stdout.write(`Note: tag "${tag}" was not present\n`);
2364
+ const updated = [...new Set([...current.filter((t) => !toRemove.includes(t)), ...toAdd])];
2365
+ const raw = await readFile(found.filePath, "utf-8");
2366
+ const parsed = JSON.parse(raw);
2367
+ parsed["tags"] = updated;
2368
+ const validation = validateFeature(parsed);
2369
+ if (!validation.success) {
2370
+ process$1.stderr.write(`Validation error: ${validation.errors.join(", ")}\n`);
2371
+ process$1.exit(1);
2372
+ }
2373
+ await writeFile(found.filePath, JSON.stringify(validation.data, null, 2) + "\n", "utf-8");
2374
+ process$1.stdout.write(`✓ ${key} tags: [${updated.join(", ")}]\n`);
2375
+ });
2376
+
2377
+ //#endregion
2378
+ //#region src/commands/workspace.ts
2379
+ const LAC_DIR = ".lac";
2380
+ const COUNTER_FILE = "counter";
2381
+ /**
2382
+ * Walk up from startDir looking for a .lac/ directory.
2383
+ * Returns the absolute path to .lac/ or null if not found.
2384
+ */
2385
+ function findLacDir(startDir) {
2386
+ let current = resolve(startDir);
2387
+ while (true) {
2388
+ const candidate = join(current, LAC_DIR);
2389
+ if (existsSync(candidate)) return candidate;
2390
+ const parent = dirname(current);
2391
+ if (parent === current) return null;
2392
+ current = parent;
2393
+ }
2394
+ }
2395
+ const workspaceCommand = new Command("workspace").description("Manage the lac workspace (.lac/ directory)");
2396
+ workspaceCommand.command("init").description("Initialise a lac workspace in the current directory").argument("[dir]", "Directory to initialise (default: current directory)").option("--force", "Re-initialise even if a .lac/ directory already exists").action((dir, options) => {
2397
+ const lacDir = join(resolve(dir ?? process$1.cwd()), LAC_DIR);
2398
+ const counterPath = join(lacDir, COUNTER_FILE);
2399
+ if (existsSync(lacDir) && !options.force) {
2400
+ process$1.stdout.write(`lac workspace already initialised at "${lacDir}".\nRun "lac workspace init --force" to reinitialise.\n`);
2401
+ return;
2402
+ }
2403
+ mkdirSync(lacDir, { recursive: true });
2404
+ if (!existsSync(counterPath) || options.force) writeFileSync(counterPath, `${(/* @__PURE__ */ new Date()).getFullYear()}\n0\n`, "utf-8");
2405
+ process$1.stdout.write(`✓ Initialised lac workspace at "${lacDir}"\n`);
2406
+ process$1.stdout.write(` Run "lac init" inside a feature folder to create a feature.json.\n`);
2407
+ });
2408
+ workspaceCommand.command("status").description("Show workspace info (location, counter, next key, feature stats)").action(async () => {
2409
+ const lacDir = findLacDir(process$1.cwd());
2410
+ if (!lacDir) {
2411
+ process$1.stdout.write("No .lac/ workspace found in current directory or any parent.\nRun \"lac workspace init\" to create one.\n");
2412
+ return;
2413
+ }
2414
+ const counterPath = join(lacDir, COUNTER_FILE);
2415
+ if (!existsSync(counterPath)) {
2416
+ process$1.stdout.write(`Workspace : ${lacDir}\nCounter : not initialised\n`);
2417
+ return;
2418
+ }
2419
+ const lines = readFileSync(counterPath, "utf-8").trim().split("\n").map((l) => l.trim());
2420
+ const year = lines[0] ?? "?";
2421
+ const counterStr = lines[1] ?? "0";
2422
+ const counter = parseInt(counterStr, 10);
2423
+ const next = isNaN(counter) ? "001" : String(counter + 1).padStart(3, "0");
2424
+ process$1.stdout.write(`Workspace : ${lacDir}\n`);
2425
+ process$1.stdout.write(`Counter : ${year}/${counterStr}\n`);
2426
+ process$1.stdout.write(`Next key : feat-${year}-${next}\n`);
2427
+ const workspaceRoot = resolve(lacDir, "..");
2428
+ try {
2429
+ const features = await scanFeatures(workspaceRoot);
2430
+ process$1.stdout.write(`Features : ${features.length}\n`);
2431
+ if (features.length === 0) process$1.stdout.write(`\nNo features found. Run "lac init" in a subdirectory to create your first feature.\n`);
2432
+ else {
2433
+ const completenessValues = features.map(({ feature }) => computeCompleteness(feature));
2434
+ const avg = Math.round(completenessValues.reduce((a, b) => a + b, 0) / completenessValues.length);
2435
+ process$1.stdout.write(`Avg compl.: ${avg}%\n`);
2436
+ }
2437
+ } catch {}
2438
+ });
2439
+
2440
+ //#endregion
2441
+ //#region src/index.ts
2442
+ const program = new Command();
2443
+ program.name("lac").description("life-as-code CLI — provenance for your features").version("0.1.0");
2444
+ program.addCommand(workspaceCommand);
2445
+ program.addCommand(spawnCommand);
2446
+ program.addCommand(initCommand);
2447
+ program.addCommand(exportCommand);
2448
+ program.addCommand(lintCommand);
2449
+ program.addCommand(blameCommand);
2450
+ program.addCommand(hooksCommand);
2451
+ program.addCommand(serveCommand);
2452
+ program.addCommand(tagCommand);
2453
+ program.addCommand(archiveCommand);
2454
+ program.addCommand(doctorCommand);
2455
+ program.addCommand(searchCommand);
2456
+ program.addCommand(statCommand);
2457
+ program.addCommand(lineageCommand);
2458
+ program.addCommand(diffCommand);
2459
+ program.addCommand(renameCommand);
2460
+ program.addCommand(importCommand);
2461
+ program.parse();
2462
+
2463
+ //#endregion
2464
+ export { };
2465
+ //# sourceMappingURL=index.mjs.map