@majeanson/lac 3.4.5 → 3.5.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 CHANGED
@@ -3781,6 +3781,10 @@ const FeatureSchema$1 = object$1({
3781
3781
  lastVerifiedDate: string$2().regex(/^\d{4}-\d{2}-\d{2}$/, "lastVerifiedDate must be YYYY-MM-DD").optional(),
3782
3782
  codeSnippets: array$1(CodeSnippetSchema$1).optional(),
3783
3783
  implementationNotes: array$1(string$2()).optional(),
3784
+ pmSummary: string$2().optional(),
3785
+ testStrategy: string$2().optional(),
3786
+ releaseVersion: string$2().optional(),
3787
+ acceptanceCriteria: array$1(string$2()).optional(),
3784
3788
  fieldLocks: array$1(FieldLockSchema$1).optional(),
3785
3789
  featureLocked: boolean$2().optional()
3786
3790
  });
@@ -7725,6 +7729,10 @@ const FeatureSchema = object({
7725
7729
  lastVerifiedDate: string().regex(/^\d{4}-\d{2}-\d{2}$/, "lastVerifiedDate must be YYYY-MM-DD").optional(),
7726
7730
  codeSnippets: array(CodeSnippetSchema).optional(),
7727
7731
  implementationNotes: array(string()).optional(),
7732
+ pmSummary: string().optional(),
7733
+ testStrategy: string().optional(),
7734
+ releaseVersion: string().optional(),
7735
+ acceptanceCriteria: array(string()).optional(),
7728
7736
  fieldLocks: array(FieldLockSchema).optional(),
7729
7737
  featureLocked: boolean().optional()
7730
7738
  });
@@ -8142,6 +8150,25 @@ Return ONLY a valid JSON array of plain strings — no other text, no markdown f
8142
8150
 
8143
8151
  If there are no notable implementation notes, return an empty array: []`,
8144
8152
  userSuffix: "Extract free-form implementation notes for this feature."
8153
+ },
8154
+ pmSummary: {
8155
+ system: `You are a product manager writing for a non-technical executive audience. Given a feature.json, write a 1–2 sentence business-value summary. Focus on the outcome and the user or business benefit — not the technical implementation. Avoid words like "component", "schema", "API", "TypeScript". Write as if you are telling a stakeholder why this feature matters. Return only the summary text, no JSON wrapper, no heading.`,
8156
+ userSuffix: "Write a 1–2 sentence PM/exec summary for this feature."
8157
+ },
8158
+ testStrategy: {
8159
+ system: `You are a software testing expert. Given a feature.json, describe in 2-4 sentences how this feature should be tested. Cover: what type of tests are appropriate (unit, integration, E2E, manual), what the hardest thing to test is, and any test setup or environment requirements implied by the feature. Be specific — name the actual components or flows that need coverage. Return only the strategy text, no JSON wrapper, no heading.`,
8160
+ userSuffix: "Describe the test strategy for this feature."
8161
+ },
8162
+ releaseVersion: {
8163
+ system: `You are a software engineering analyst. Given a feature.json, return the release version string this feature first shipped in, if it can be inferred from the statusHistory dates, revisions, or annotations. If not determinable, return an empty string. Return only the version string (e.g. "3.5.0", "v2", "2026-Q2") or an empty string — nothing else.`,
8164
+ userSuffix: "Identify the release version this feature first shipped in, or return empty string if unknown."
8165
+ },
8166
+ acceptanceCriteria: {
8167
+ system: `You are a software testing expert. Given a feature.json, break the successCriteria and problem statement into 3-8 discrete, testable acceptance criteria. Each criterion must be a single concrete, verifiable statement (e.g. "Camera roll permission prompt appears on first launch only", "Denying permission shows a recovery screen with an Open Settings button"). No vague statements. No compound criteria (one thing per item).
8168
+
8169
+ Return ONLY a valid JSON array of strings — no other text:
8170
+ ["criterion 1", "criterion 2", "criterion 3"]`,
8171
+ userSuffix: "Generate structured acceptance criteria for this feature."
8145
8172
  }
8146
8173
  };
8147
8174
  const JSON_FIELDS = new Set([
@@ -8152,7 +8179,9 @@ const JSON_FIELDS = new Set([
8152
8179
  "npmPackages",
8153
8180
  "publicInterface",
8154
8181
  "externalDependencies",
8155
- "codeSnippets"
8182
+ "codeSnippets",
8183
+ "implementationNotes",
8184
+ "acceptanceCriteria"
8156
8185
  ]);
8157
8186
  const ALL_FILLABLE_FIELDS = [
8158
8187
  "analysis",
@@ -8162,6 +8191,9 @@ const ALL_FILLABLE_FIELDS = [
8162
8191
  "tags",
8163
8192
  "successCriteria",
8164
8193
  "userGuide",
8194
+ "pmSummary",
8195
+ "testStrategy",
8196
+ "acceptanceCriteria",
8165
8197
  "domain",
8166
8198
  "componentFile",
8167
8199
  "npmPackages",
@@ -8169,7 +8201,8 @@ const ALL_FILLABLE_FIELDS = [
8169
8201
  "externalDependencies",
8170
8202
  "lastVerifiedDate",
8171
8203
  "codeSnippets",
8172
- "implementationNotes"
8204
+ "implementationNotes",
8205
+ "releaseVersion"
8173
8206
  ];
8174
8207
  function getMissingFields(feature) {
8175
8208
  return ALL_FILLABLE_FIELDS.filter((field) => {
@@ -8186,7 +8219,7 @@ const GEN_PROMPTS = {
8186
8219
  userSuffix: "Generate a React TypeScript component for this feature."
8187
8220
  },
8188
8221
  test: {
8189
- system: `You are an expert software testing engineer. You will be given a feature.json. Generate a comprehensive test suite using Vitest. Use the successCriteria to derive happy-path tests and the knownLimitations to derive edge-case tests. Return only the test code, no explanation.`,
8222
+ system: `You are an expert software testing engineer. You will be given a feature.json. Generate a comprehensive test suite using Vitest. Primary inputs: use acceptanceCriteria[] for happy-path test cases (one test per criterion), testStrategy to determine the appropriate test type (unit/integration/E2E), and knownLimitations for edge-case tests. If acceptanceCriteria is absent, derive tests from successCriteria. Return only the test code, no explanation.`,
8190
8223
  userSuffix: "Generate a Vitest test suite for this feature."
8191
8224
  },
8192
8225
  migration: {
@@ -8196,6 +8229,10 @@ const GEN_PROMPTS = {
8196
8229
  docs: {
8197
8230
  system: `You are a technical writer. You will be given a feature.json. Generate user-facing documentation for this feature. Write it clearly enough that any end user can understand it (not developer-focused). Cover: what it does, how to use it, and known limitations. Use Markdown. Return only the documentation, no explanation.`,
8198
8231
  userSuffix: "Generate user-facing documentation for this feature."
8232
+ },
8233
+ mock: {
8234
+ system: `You are an expert TypeScript developer. You will be given a feature.json with a publicInterface[] array describing exported types, components, hooks, services, and functions. Generate realistic TypeScript mock factories for each entry. Use hand-crafted realistic static values (no external faker library). Export a factory function for each interface named \`mock<Name>(overrides?: Partial<Type>): Type\`. Where the shape is not fully defined in the feature, make reasonable assumptions from the name and description. Return only the TypeScript code, no explanation.`,
8235
+ userSuffix: "Generate TypeScript mock factories for this feature."
8199
8236
  }
8200
8237
  };
8201
8238
  const PROMPT_LOG_FILENAME = "prompt.log.jsonl";
@@ -8468,29 +8505,183 @@ async function genFromFeature(options) {
8468
8505
  const result = validateFeature(JSON.parse(raw));
8469
8506
  if (!result.success) throw new Error(`Invalid feature.json: ${result.errors.join(", ")}`);
8470
8507
  const feature = result.data;
8508
+ if (type === "types") return writeOrPrint(genTypes(feature), options.outFile ?? path.join(featureDir, `${feature["featureKey"]}.types.ts`), dryRun, type);
8509
+ if (type === "adr") return writeOrPrint(genAdr(feature), options.outFile ?? path.join(featureDir, "ARCHITECTURE-DECISIONS.md"), dryRun, type);
8510
+ if (type === "snippets") return writeOrPrint(genSnippets(feature), options.outFile ?? path.join(featureDir, `${feature["featureKey"]}-snippets.md`), dryRun, type);
8471
8511
  const promptConfig = GEN_PROMPTS[type];
8472
- if (!promptConfig) throw new Error(`Unknown generation type: "${type}". Available: component, test, migration, docs`);
8512
+ if (!promptConfig) throw new Error(`Unknown generation type: "${type}". Available: component, test, migration, docs, types, adr, snippets, mock`);
8473
8513
  const client = createClient();
8474
- process$1.stdout.write(`\nGenerating ${type} for ${feature.featureKey} (${feature.title})...\n`);
8514
+ process$1.stdout.write(`\nGenerating ${type} for ${feature["featureKey"]} (${feature["title"]})...\n`);
8475
8515
  process$1.stdout.write(`Model: ${model}\n\n`);
8476
- const contextStr = contextToString(buildContext(featureDir, feature));
8477
- const generated = await generateText(client, promptConfig.system, `${contextStr}\n\n${promptConfig.userSuffix}`, model);
8516
+ const contextStr = contextToString(buildContext(featureDir, result.data));
8517
+ return writeOrPrint(await generateText(client, promptConfig.system, `${contextStr}\n\n${promptConfig.userSuffix}`, model), options.outFile ?? path.join(featureDir, `${feature["featureKey"]}${typeToExt(type)}`), dryRun, type);
8518
+ }
8519
+ function writeOrPrint(content, outFile, dryRun, type) {
8478
8520
  if (dryRun) {
8479
- process$1.stdout.write(generated);
8480
- process$1.stdout.write("\n\n [dry-run] No file written.\n");
8481
- return generated;
8521
+ process$1.stdout.write(content);
8522
+ process$1.stdout.write(`\n\n [dry-run] No file written.\n`);
8523
+ return content;
8524
+ }
8525
+ fs.writeFileSync(outFile, content, "utf-8");
8526
+ process$1.stdout.write(` ✓ Generated ${type} → ${outFile}\n\n`);
8527
+ return content;
8528
+ }
8529
+ function genTypes(feature) {
8530
+ const iface = feature["publicInterface"];
8531
+ const lines = [
8532
+ `// Generated by lac gen --type types`,
8533
+ `// Feature: ${feature["featureKey"]} — ${feature["title"]}`,
8534
+ `// Do not edit manually — regenerate with: lac gen --type types`,
8535
+ ``
8536
+ ];
8537
+ if (!iface || iface.length === 0) {
8538
+ lines.push(`// No publicInterface entries found in feature.json`);
8539
+ lines.push(`// Add publicInterface[] to the feature to generate type declarations`);
8540
+ return lines.join("\n");
8541
+ }
8542
+ for (const entry of iface) {
8543
+ if (entry.description) lines.push(`/** ${entry.description} */`);
8544
+ const lower = entry.type.toLowerCase();
8545
+ const name = entry.name;
8546
+ if (lower.includes("react component") || lower.includes("component") && !lower.includes("context")) {
8547
+ lines.push(`export interface ${name}Props {`);
8548
+ lines.push(` // TODO: define props`);
8549
+ lines.push(`}`);
8550
+ lines.push(`export declare function ${name}(props: ${name}Props): JSX.Element`);
8551
+ } else if (lower.includes("context") || lower.includes("provider")) {
8552
+ const valueName = `${name}Value`;
8553
+ lines.push(`export interface ${valueName} {`);
8554
+ lines.push(` // TODO: define context value shape`);
8555
+ lines.push(`}`);
8556
+ lines.push(`export declare const ${name}: React.Context<${valueName}>`);
8557
+ } else if (lower.includes("hook") || name.startsWith("use")) lines.push(`export declare function ${name}(): unknown // TODO: define return type`);
8558
+ else if (lower.includes("service") || lower.includes("manager") || lower.includes("class")) {
8559
+ lines.push(`export declare class ${name} {`);
8560
+ lines.push(` // TODO: define service methods`);
8561
+ lines.push(`}`);
8562
+ } else if (lower.includes("function") || lower.includes("util") || lower.includes("helper")) lines.push(`export declare function ${name}(...args: unknown[]): unknown // TODO: define signature`);
8563
+ else {
8564
+ lines.push(`export interface ${name} {`);
8565
+ lines.push(` // TODO: define interface shape`);
8566
+ lines.push(`}`);
8567
+ }
8568
+ lines.push(``);
8569
+ }
8570
+ return lines.join("\n");
8571
+ }
8572
+ function genAdr(feature) {
8573
+ const decisions = feature["decisions"];
8574
+ const featureKey = feature["featureKey"];
8575
+ const title = feature["title"];
8576
+ const problem = feature["problem"];
8577
+ const analysis = feature["analysis"];
8578
+ const lines = [
8579
+ `# Architecture Decision Records — ${title}`,
8580
+ ``,
8581
+ `> Feature: \`${featureKey}\` `,
8582
+ `> Generated by: \`lac gen --type adr\``,
8583
+ ``,
8584
+ `---`,
8585
+ ``
8586
+ ];
8587
+ if (!decisions || decisions.length === 0) {
8588
+ lines.push(`*No decisions recorded in this feature.json.*`);
8589
+ lines.push(``);
8590
+ lines.push(`Add \`decisions[]\` to the feature to generate ADRs.`);
8591
+ return lines.join("\n");
8592
+ }
8593
+ decisions.forEach((d, i) => {
8594
+ d.decision.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
8595
+ lines.push(`## ADR-${String(i + 1).padStart(3, "0")}: ${d.decision}`);
8596
+ lines.push(``);
8597
+ lines.push(`**Status:** Accepted `);
8598
+ if (d.date) lines.push(`**Date:** ${d.date} `);
8599
+ lines.push(`**Feature:** \`${featureKey}\``);
8600
+ lines.push(``);
8601
+ lines.push(`### Context`);
8602
+ lines.push(``);
8603
+ if (problem) {
8604
+ lines.push(problem);
8605
+ lines.push(``);
8606
+ }
8607
+ if (analysis) {
8608
+ lines.push(analysis);
8609
+ lines.push(``);
8610
+ }
8611
+ lines.push(`### Decision`);
8612
+ lines.push(``);
8613
+ lines.push(d.decision);
8614
+ lines.push(``);
8615
+ lines.push(`### Rationale`);
8616
+ lines.push(``);
8617
+ lines.push(d.rationale);
8618
+ lines.push(``);
8619
+ if (d.alternativesConsidered && d.alternativesConsidered.length > 0) {
8620
+ lines.push(`### Alternatives Considered`);
8621
+ lines.push(``);
8622
+ for (const alt of d.alternativesConsidered) lines.push(`- ${alt}`);
8623
+ lines.push(``);
8624
+ }
8625
+ lines.push(`---`);
8626
+ lines.push(``);
8627
+ });
8628
+ return lines.join("\n");
8629
+ }
8630
+ function genSnippets(feature) {
8631
+ const snippets = feature["codeSnippets"];
8632
+ const featureKey = feature["featureKey"];
8633
+ const lines = [
8634
+ `# Code Snippets — ${feature["title"]}`,
8635
+ ``,
8636
+ `> Feature: \`${featureKey}\` `,
8637
+ `> Generated by: \`lac gen --type snippets\``,
8638
+ ``,
8639
+ `---`,
8640
+ ``
8641
+ ];
8642
+ if (!snippets || snippets.length === 0) {
8643
+ lines.push(`*No codeSnippets recorded in this feature.json.*`);
8644
+ lines.push(``);
8645
+ lines.push(`Add \`codeSnippets[]\` to the feature to generate a snippet reference.`);
8646
+ return lines.join("\n");
8647
+ }
8648
+ for (const s of snippets) {
8649
+ lines.push(`## ${s.label}`);
8650
+ lines.push(``);
8651
+ lines.push("```");
8652
+ lines.push(s.snippet);
8653
+ lines.push("```");
8654
+ lines.push(``);
8655
+ }
8656
+ return lines.join("\n");
8657
+ }
8658
+ /**
8659
+ * Runs a custom AI generator against a single feature.json.
8660
+ * Used by the `lac gen --generator <name>` plugin system for type:"ai" generators.
8661
+ */
8662
+ async function genWithCustomPrompt(options) {
8663
+ const { featureDir, systemPrompt, dryRun = false, model = "claude-sonnet-4-6" } = options;
8664
+ const featurePath = path.join(featureDir, "feature.json");
8665
+ let raw;
8666
+ try {
8667
+ raw = fs.readFileSync(featurePath, "utf-8");
8668
+ } catch {
8669
+ throw new Error(`No feature.json found at "${featurePath}"`);
8482
8670
  }
8483
- const outFile = options.outFile ?? path.join(featureDir, `${feature.featureKey}${typeToExt(type)}`);
8484
- fs.writeFileSync(outFile, generated, "utf-8");
8485
- process$1.stdout.write(` ✓ Written to ${outFile}\n\n`);
8486
- return generated;
8671
+ const result = validateFeature(JSON.parse(raw));
8672
+ if (!result.success) throw new Error(`Invalid feature.json: ${result.errors.join(", ")}`);
8673
+ const feature = result.data;
8674
+ const client = createClient();
8675
+ process$1.stdout.write(` Running AI generator for ${feature["featureKey"]} (${feature["title"]})...\n`);
8676
+ return writeOrPrint(await generateText(client, systemPrompt, `${contextToString(buildContext(featureDir, result.data))}\n\nGenerate the requested output for this feature.`, model), options.outFile ?? path.join(featureDir, `${feature["featureKey"]}-generated.txt`), dryRun, "custom");
8487
8677
  }
8488
8678
  function typeToExt(type) {
8489
8679
  return {
8490
8680
  component: ".tsx",
8491
8681
  test: ".test.ts",
8492
8682
  migration: ".sql",
8493
- docs: ".md"
8683
+ docs: ".md",
8684
+ mock: ".mock.ts"
8494
8685
  }[type] ?? ".txt";
8495
8686
  }
8496
8687
  function askUser$1(question) {
@@ -9018,7 +9209,9 @@ const DEFAULTS = {
9018
9209
  restrictedFields: [],
9019
9210
  requireAlternatives: false,
9020
9211
  freezeRequiresHumanRevision: false
9021
- }
9212
+ },
9213
+ views: {},
9214
+ generators: {}
9022
9215
  };
9023
9216
  function loadConfig(fromDir) {
9024
9217
  const configPath = findLacConfig(fromDir ?? process$1.cwd());
@@ -9038,7 +9231,9 @@ function loadConfig(fromDir) {
9038
9231
  restrictedFields: parsed.guardlock?.restrictedFields ?? DEFAULTS.guardlock.restrictedFields,
9039
9232
  requireAlternatives: parsed.guardlock?.requireAlternatives ?? DEFAULTS.guardlock.requireAlternatives,
9040
9233
  freezeRequiresHumanRevision: parsed.guardlock?.freezeRequiresHumanRevision ?? DEFAULTS.guardlock.freezeRequiresHumanRevision
9041
- }
9234
+ },
9235
+ views: parsed.views ?? DEFAULTS.views,
9236
+ generators: parsed.generators ?? DEFAULTS.generators
9042
9237
  };
9043
9238
  } catch {
9044
9239
  process$1.stderr.write(`Warning: could not parse lac.config.json at "${configPath}" — using defaults\n`);
@@ -9184,22 +9379,85 @@ const configCommand = new Command("config").description("Show the resolved lac.c
9184
9379
  });
9185
9380
  //#endregion
9186
9381
  //#region src/commands/gen.ts
9187
- const GEN_TYPES = [
9382
+ const FEATURE_GEN_TYPES = [
9188
9383
  "component",
9189
9384
  "test",
9190
9385
  "migration",
9191
- "docs"
9386
+ "docs",
9387
+ "types",
9388
+ "adr",
9389
+ "snippets",
9390
+ "mock"
9192
9391
  ];
9193
- const genCommand = new Command("gen").description("Generate code artifacts from a feature.json (component, test, migration, docs)").argument("[dir]", "Feature folder (default: nearest feature.json from cwd)").option(`--type <type>`, `What to generate: ${GEN_TYPES.join(", ")}`).option("--dry-run", "Print generated content without writing to disk").option("--out <file>", "Output file path (default: auto-named next to feature.json)").option("--model <model>", "Claude model to use (default: claude-sonnet-4-6)").action(async (dir, options) => {
9392
+ const WORKSPACE_GEN_TYPES = ["index"];
9393
+ const ALL_GEN_TYPES = [...FEATURE_GEN_TYPES, ...WORKSPACE_GEN_TYPES];
9394
+ const genCommand = new Command("gen").description("Generate code artifacts from feature.json(s)").argument("[dir]", "Feature folder or workspace root (default: nearest feature.json / cwd)").option(`--type <type>`, `Built-in generator: ${ALL_GEN_TYPES.join(", ")}`).option("--generator <name>", "Run a named generator from lac.config.json generators block").option("--list", "List all available generators (built-in + config-defined)").option("--dry-run", "Print generated content without writing to disk").option("--out <file>", "Output file path (default: auto-named)").option("--model <model>", "Claude model to use (default: claude-sonnet-4-6)").action(async (dir, options) => {
9395
+ const cwd = process$1.cwd();
9396
+ const startDir = dir ? resolve(dir) : cwd;
9397
+ const config = loadConfig(startDir);
9398
+ const configFilePath = findLacConfig(startDir);
9399
+ const configDir = configFilePath ? dirname(configFilePath) : startDir;
9400
+ if (options.list) {
9401
+ process$1.stdout.write("\nBuilt-in generator types:\n");
9402
+ for (const t of ALL_GEN_TYPES) {
9403
+ const scope = WORKSPACE_GEN_TYPES.includes(t) ? "workspace" : "feature";
9404
+ process$1.stdout.write(` ${t.padEnd(12)} [${scope}]\n`);
9405
+ }
9406
+ const customEntries = Object.entries(config.generators);
9407
+ if (customEntries.length === 0) {
9408
+ process$1.stdout.write("\nCustom generators (lac.config.json): none\n");
9409
+ process$1.stdout.write(" Add a \"generators\" block to your lac.config.json to define custom generators.\n");
9410
+ } else {
9411
+ process$1.stdout.write("\nCustom generators (lac.config.json):\n");
9412
+ for (const [name, gen] of customEntries) {
9413
+ const scope = gen.scope ?? "feature";
9414
+ const desc = gen.description ? ` — ${gen.description}` : "";
9415
+ process$1.stdout.write(` ${name.padEnd(16)} [${gen.type}/${scope}]${desc}\n`);
9416
+ }
9417
+ }
9418
+ process$1.stdout.write("\n");
9419
+ return;
9420
+ }
9421
+ if (options.generator) {
9422
+ const genDef = config.generators[options.generator];
9423
+ if (!genDef) {
9424
+ const available = Object.keys(config.generators);
9425
+ process$1.stderr.write(`Unknown generator "${options.generator}". Available: ${available.length > 0 ? available.join(", ") : "(none — add generators to lac.config.json)"}\n`);
9426
+ process$1.exit(1);
9427
+ }
9428
+ try {
9429
+ await runPluginGenerator(options.generator, genDef, startDir, configDir, {
9430
+ dryRun: options.dryRun ?? false,
9431
+ out: options.out,
9432
+ model: options.model
9433
+ });
9434
+ } catch (err) {
9435
+ process$1.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
9436
+ process$1.exit(1);
9437
+ }
9438
+ return;
9439
+ }
9194
9440
  const type = options.type ?? "component";
9195
- if (!GEN_TYPES.includes(type)) {
9196
- process$1.stderr.write(`Unknown type "${type}". Available: ${GEN_TYPES.join(", ")}\n`);
9441
+ if (!ALL_GEN_TYPES.includes(type)) {
9442
+ process$1.stderr.write(`Unknown type "${type}". Available: ${ALL_GEN_TYPES.join(", ")}\n`);
9197
9443
  process$1.exit(1);
9198
9444
  }
9445
+ if (type === "index") {
9446
+ try {
9447
+ await runIndexGenerator(startDir, {
9448
+ dryRun: options.dryRun ?? false,
9449
+ out: options.out
9450
+ });
9451
+ } catch (err) {
9452
+ process$1.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
9453
+ process$1.exit(1);
9454
+ }
9455
+ return;
9456
+ }
9199
9457
  let featureDir;
9200
9458
  if (dir) featureDir = resolve(dir);
9201
9459
  else {
9202
- const found = findNearestFeatureJson$1(process$1.cwd());
9460
+ const found = findNearestFeatureJson$1(cwd);
9203
9461
  if (!found) {
9204
9462
  process$1.stderr.write("No feature.json found from current directory.\nRun \"lac init\" to create one, or pass a path: lac gen src/auth/ --type test\n");
9205
9463
  process$1.exit(1);
@@ -9219,6 +9477,145 @@ const genCommand = new Command("gen").description("Generate code artifacts from
9219
9477
  process$1.exit(1);
9220
9478
  }
9221
9479
  });
9480
+ async function runIndexGenerator(startDir, opts) {
9481
+ process$1.stdout.write(`\nScanning workspace for publicInterface entries...\n`);
9482
+ const scanned = await scanFeatures(startDir);
9483
+ const lines = [
9484
+ `// Generated by lac gen --type index`,
9485
+ `// Workspace public-interface barrel`,
9486
+ `// Do not edit manually — regenerate with: lac gen --type index`,
9487
+ `// Generated: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`,
9488
+ ``
9489
+ ];
9490
+ let totalExports = 0;
9491
+ let featuresWithInterface = 0;
9492
+ for (const { feature } of scanned) {
9493
+ const feat = feature;
9494
+ const iface = feat["publicInterface"];
9495
+ if (!iface || iface.length === 0) continue;
9496
+ featuresWithInterface++;
9497
+ const file = typeof feat["componentFile"] === "string" ? feat["componentFile"] : void 0;
9498
+ lines.push(`// ── ${feature.featureKey} — ${feature.title}`);
9499
+ for (const e of iface) {
9500
+ const importPath = file ? file.split(",")[0]?.trim() ?? feature.featureKey : `./${feature.featureKey}`;
9501
+ lines.push(`// export { ${e.name} } from '${importPath}'`);
9502
+ totalExports++;
9503
+ }
9504
+ lines.push(``);
9505
+ }
9506
+ if (totalExports === 0) {
9507
+ lines.push(`// No publicInterface entries found across ${scanned.length} feature(s).`);
9508
+ lines.push(`// Add publicInterface[] to your feature.jsons to populate this barrel.`);
9509
+ lines.push(``);
9510
+ }
9511
+ const content = lines.join("\n");
9512
+ const outPath = opts.out ?? resolve(startDir, "index.generated.ts");
9513
+ if (opts.dryRun) {
9514
+ process$1.stdout.write(content);
9515
+ process$1.stdout.write(`\n\n [dry-run] No file written.\n`);
9516
+ return;
9517
+ }
9518
+ writeFileSync(outPath, content, "utf-8");
9519
+ process$1.stdout.write(` ✓ index → ${outPath}\n ${totalExports} export(s) from ${featuresWithInterface}/${scanned.length} features\n\n`);
9520
+ }
9521
+ async function runPluginGenerator(name, gen, startDir, configDir, opts) {
9522
+ const scope = gen.scope ?? "feature";
9523
+ let features;
9524
+ if (scope === "workspace") features = (await scanFeatures(startDir)).map((s) => s.feature).filter((f) => {
9525
+ if (gen.filterStatus && !gen.filterStatus.includes(f["status"])) return false;
9526
+ if (gen.filterTags) {
9527
+ const ftags = f["tags"] ?? [];
9528
+ if (!gen.filterTags.some((t) => ftags.includes(t))) return false;
9529
+ }
9530
+ return true;
9531
+ });
9532
+ else {
9533
+ const found = findNearestFeatureJson$1(startDir);
9534
+ if (!found) throw new Error(`No feature.json found from "${startDir}"`);
9535
+ const raw = readFileSync(found, "utf-8");
9536
+ features = [JSON.parse(raw)];
9537
+ }
9538
+ process$1.stdout.write(`\nGenerator "${name}" [${gen.type}/${scope}] — ${features.length} feature(s)\n`);
9539
+ if (gen.type === "script") {
9540
+ if (!gen.script) throw new Error(`Generator "${name}": missing "script" path.`);
9541
+ const scriptPath = resolve(configDir, gen.script);
9542
+ const input = JSON.stringify(scope === "workspace" ? features : features[0], null, 2);
9543
+ const result = spawnSync("node", [scriptPath], {
9544
+ input,
9545
+ encoding: "utf-8",
9546
+ cwd: configDir
9547
+ });
9548
+ if (result.error || result.status !== 0) {
9549
+ const msg = result.stderr || (result.error?.message ?? `exit ${result.status}`);
9550
+ throw new Error(`Script failed: ${msg}`);
9551
+ }
9552
+ writeOrPrintPlugin(result.stdout ?? "", name, gen, features, configDir, opts);
9553
+ } else if (gen.type === "template") {
9554
+ if (!gen.template) throw new Error(`Generator "${name}": missing "template" path.`);
9555
+ const templatePath = resolve(configDir, gen.template);
9556
+ let template;
9557
+ try {
9558
+ template = readFileSync(templatePath, "utf-8");
9559
+ } catch {
9560
+ throw new Error(`Cannot read template: ${templatePath}`);
9561
+ }
9562
+ if (scope === "workspace") {
9563
+ const ctx = {
9564
+ features,
9565
+ count: features.length
9566
+ };
9567
+ writeOrPrintPlugin(renderTemplate(template, ctx), name, gen, features, configDir, opts);
9568
+ } else for (const f of features) writeOrPrintPlugin(renderTemplate(template, f), name, gen, [f], configDir, opts);
9569
+ } else if (gen.type === "ai") {
9570
+ if (!gen.systemPrompt) throw new Error(`Generator "${name}": missing "systemPrompt".`);
9571
+ let systemPrompt;
9572
+ if (gen.systemPrompt.endsWith(".md") || gen.systemPrompt.endsWith(".txt")) try {
9573
+ systemPrompt = readFileSync(resolve(configDir, gen.systemPrompt), "utf-8");
9574
+ } catch {
9575
+ throw new Error(`Cannot read systemPrompt file: ${gen.systemPrompt}`);
9576
+ }
9577
+ else systemPrompt = gen.systemPrompt;
9578
+ for (const f of features) {
9579
+ const featureDir = startDir;
9580
+ const outFile = resolveOutputPath(opts.out ?? gen.outputFile, f, configDir);
9581
+ await genWithCustomPrompt({
9582
+ featureDir,
9583
+ systemPrompt,
9584
+ dryRun: opts.dryRun,
9585
+ outFile,
9586
+ model: opts.model
9587
+ });
9588
+ }
9589
+ }
9590
+ }
9591
+ /** Lightweight template engine: replaces {{field}} with feature values */
9592
+ function renderTemplate(template, ctx) {
9593
+ return template.replace(/\{\{(\w+)\}\}/g, (_, key) => {
9594
+ const val = ctx[key];
9595
+ if (val === void 0 || val === null) return "";
9596
+ if (typeof val === "string") return val;
9597
+ return JSON.stringify(val);
9598
+ });
9599
+ }
9600
+ function resolveOutputPath(pattern, f, startDir) {
9601
+ if (!pattern) return void 0;
9602
+ const slug = String(f["title"] ?? "untitled").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
9603
+ return resolve(startDir, pattern.replace(/\{featureKey\}/g, String(f["featureKey"] ?? "unknown")).replace(/\{domain\}/g, String(f["domain"] ?? "misc")).replace(/\{status\}/g, String(f["status"] ?? "draft")).replace(/\{title\}/g, slug));
9604
+ }
9605
+ function writeOrPrintPlugin(output, name, gen, features, baseDir, opts) {
9606
+ if (opts.dryRun) {
9607
+ process$1.stdout.write(output);
9608
+ process$1.stdout.write(`\n\n [dry-run] No file written.\n`);
9609
+ return;
9610
+ }
9611
+ const outPath = resolveOutputPath(opts.out ?? gen.outputFile, features[0] ?? {}, baseDir);
9612
+ if (!outPath) {
9613
+ process$1.stdout.write(output);
9614
+ return;
9615
+ }
9616
+ writeFileSync(outPath, output, "utf-8");
9617
+ process$1.stdout.write(` ✓ "${name}" → ${outPath}\n`);
9618
+ }
9222
9619
  //#endregion
9223
9620
  //#region src/commands/guardlock.ts
9224
9621
  /**
@@ -21449,159 +21846,1267 @@ gsearch.addEventListener('keydown', e => {
21449
21846
  </html>`;
21450
21847
  }
21451
21848
  //#endregion
21452
- //#region src/lib/hubGenerator.ts
21453
- /** Canonical ordered entry definitions for all standard LAC outputs. */
21454
- const ALL_HUB_ENTRIES = [
21455
- {
21456
- file: "lac-guide.html",
21457
- label: "User Guide",
21458
- description: "How to use every user-facing feature — generated from userGuide fields",
21459
- icon: "📖",
21460
- primary: true
21461
- },
21462
- {
21463
- file: "lac-story.html",
21464
- label: "Product Story",
21465
- description: "Long-form narrative case study built from feature data",
21466
- icon: "📰",
21467
- primary: true
21468
- },
21469
- {
21470
- file: "lac-wiki.html",
21471
- label: "Feature Wiki",
21472
- description: "Complete searchable wiki — all fields, all features, sidebar navigation",
21473
- icon: "🗂️",
21474
- primary: false
21475
- },
21476
- {
21477
- file: "lac-kanban.html",
21478
- label: "Kanban Board",
21479
- description: "Active / Frozen / Draft columns with sortable, filterable cards",
21480
- icon: "📋",
21481
- primary: false
21482
- },
21483
- {
21484
- file: "lac-health.html",
21485
- label: "Health Scorecard",
21486
- description: "Completeness, coverage, tech-debt score, and field fill rates",
21487
- icon: "🏥",
21488
- primary: false
21489
- },
21490
- {
21491
- file: "lac-decisions.html",
21492
- label: "Decision Log",
21493
- description: "All architectural decisions consolidated and searchable by domain",
21494
- icon: "⚖️",
21495
- primary: false
21496
- },
21497
- {
21498
- file: "lac-heatmap.html",
21499
- label: "Completeness Heatmap",
21500
- description: "Field x feature completeness grid — quickly spot gaps",
21501
- icon: "🔥",
21502
- primary: false
21503
- },
21504
- {
21505
- file: "lac-graph.html",
21506
- label: "Lineage Graph",
21507
- description: "Interactive force-directed feature dependency graph",
21508
- icon: "🕸️",
21509
- primary: false
21510
- },
21511
- {
21512
- file: "lac-print.html",
21513
- label: "Print",
21514
- description: "Print-ready A4 document — all features in clean two-column layout",
21515
- icon: "🖨️",
21516
- primary: false
21517
- },
21518
- {
21519
- file: "lac-raw.html",
21520
- label: "Raw Dump",
21521
- description: "Field-by-field dump of every feature.json with sidebar navigation",
21522
- icon: "🔩",
21523
- primary: false
21524
- }
21525
- ];
21526
- function generateHub(projectName, stats, entries, generatedAt = (/* @__PURE__ */ new Date()).toISOString(), prefix) {
21849
+ //#region src/lib/changelogGenerator.ts
21850
+ /**
21851
+ * generateChangelog aggregates all revision[] entries across the workspace,
21852
+ * sorts by date descending, groups by month, and renders a structured HTML changelog.
21853
+ *
21854
+ * Output: lac-changelog.html
21855
+ */
21856
+ function generateChangelog(features, projectName, since) {
21527
21857
  function esc(s) {
21528
21858
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
21529
21859
  }
21530
- const urlPrefix = prefix ? "/" + (/[A-Za-z]:[/\\]/.test(prefix) ? prefix.split(/[/\\]/).filter(Boolean).pop() ?? "" : prefix.replace(/^\/+/, "").replace(/\/+$/, "")) : "";
21531
- function href(file) {
21532
- return urlPrefix ? `${urlPrefix}/${file}` : `./${file}`;
21533
- }
21534
- const primaryEntries = entries.filter((e) => e.primary);
21535
- const secondaryEntries = entries.filter((e) => !e.primary);
21536
- const date = new Date(generatedAt).toLocaleDateString("en-US", {
21537
- year: "numeric",
21538
- month: "long",
21539
- day: "numeric"
21540
- });
21541
- function primaryCard(e) {
21542
- return `
21543
- <a href="${esc(href(e.file))}" class="primary-card">
21544
- <div class="primary-card-icon">${e.icon}</div>
21545
- <div class="primary-card-body">
21546
- <div class="primary-card-label">${esc(e.label)}</div>
21547
- <div class="primary-card-desc">${esc(e.description)}</div>
21548
- </div>
21549
- <div class="primary-card-arrow">→</div>
21550
- </a>`;
21860
+ const entries = [];
21861
+ for (const f of features) {
21862
+ const revisions = f["revisions"];
21863
+ if (!revisions) continue;
21864
+ for (const r of revisions) {
21865
+ if (since && r.date < since) continue;
21866
+ entries.push({
21867
+ date: r.date,
21868
+ author: r.author,
21869
+ fields_changed: r.fields_changed,
21870
+ reason: r.reason,
21871
+ featureKey: f.featureKey,
21872
+ featureTitle: f.title,
21873
+ featureDomain: f.domain ?? "misc",
21874
+ featureStatus: f.status
21875
+ });
21876
+ }
21551
21877
  }
21552
- function secondaryCard(e) {
21553
- return `
21554
- <a href="${esc(href(e.file))}" class="secondary-card">
21555
- <div class="secondary-card-icon">${e.icon}</div>
21556
- <div class="secondary-card-label">${esc(e.label)}</div>
21557
- <div class="secondary-card-desc">${esc(e.description)}</div>
21558
- </a>`;
21878
+ entries.sort((a, b) => b.date.localeCompare(a.date));
21879
+ const byMonth = /* @__PURE__ */ new Map();
21880
+ for (const e of entries) {
21881
+ const month = e.date.slice(0, 7);
21882
+ const group = byMonth.get(month) ?? [];
21883
+ group.push(e);
21884
+ byMonth.set(month, group);
21885
+ }
21886
+ const months = [...byMonth.keys()].sort((a, b) => b.localeCompare(a));
21887
+ function monthLabel(ym) {
21888
+ const [y, m] = ym.split("-");
21889
+ return new Date(Number(y), Number(m) - 1, 1).toLocaleString("en-US", {
21890
+ month: "long",
21891
+ year: "numeric"
21892
+ });
21559
21893
  }
21560
- const domainList = stats.domains.length > 0 ? stats.domains.slice(0, 6).map((d) => `<span class="domain-tag">${esc(d)}</span>`).join("") + (stats.domains.length > 6 ? `<span class="domain-tag">+${stats.domains.length - 6}</span>` : "") : "";
21894
+ function statusColor(s) {
21895
+ return s === "frozen" ? "var(--status-frozen)" : s === "active" ? "var(--status-active)" : s === "draft" ? "var(--status-draft)" : "var(--status-deprecated)";
21896
+ }
21897
+ const navHtml = months.map((m, i) => `<a class="nav-item${i === 0 ? " active" : ""}" data-month="${esc(m)}" href="#${esc(m)}">${esc(monthLabel(m))} <span class="nav-count">${byMonth.get(m).length}</span></a>`).join("\n");
21898
+ const sectionsHtml = months.map((m, i) => {
21899
+ const revs = byMonth.get(m);
21900
+ const items = revs.map((r) => `
21901
+ <div class="rev-card">
21902
+ <div class="rev-card-top">
21903
+ <div class="rev-feature">
21904
+ <span class="rev-key">${esc(r.featureKey)}</span>
21905
+ <span class="rev-title">${esc(r.featureTitle)}</span>
21906
+ <span class="rev-domain">${esc(r.featureDomain.replace(/-/g, " "))}</span>
21907
+ <span class="rev-status" style="color:${statusColor(r.featureStatus)}">${esc(r.featureStatus)}</span>
21908
+ </div>
21909
+ <div class="rev-meta">
21910
+ <span class="rev-date">${esc(r.date)}</span>
21911
+ <span class="rev-author">${esc(r.author)}</span>
21912
+ </div>
21913
+ </div>
21914
+ <div class="rev-reason">${esc(r.reason)}</div>
21915
+ <div class="rev-fields">${r.fields_changed.map((f) => `<span class="rev-field">${esc(f)}</span>`).join("")}</div>
21916
+ </div>`).join("");
21917
+ return `<section id="${esc(m)}" class="month-section${i > 0 ? " hidden" : ""}">
21918
+ <div class="month-header">
21919
+ <div class="month-title">${esc(monthLabel(m))}</div>
21920
+ <div class="month-count">${revs.length} change${revs.length !== 1 ? "s" : ""}</div>
21921
+ </div>
21922
+ ${items}
21923
+ </section>`;
21924
+ }).join("\n");
21925
+ const sinceNote = since ? ` · since ${since}` : "";
21561
21926
  return `<!DOCTYPE html>
21562
21927
  <html lang="en">
21563
21928
  <head>
21564
21929
  <meta charset="UTF-8">
21565
21930
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
21566
- <title>${esc(projectName)} — Life-as-Code Hub</title>
21931
+ <title>${esc(projectName)} — Changelog</title>
21567
21932
  <style>
21568
21933
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
21569
21934
  :root {
21570
- --bg: #0f0d0b;
21571
- --bg-card: #181512;
21572
- --bg-hover: #1e1a16;
21573
- --border: #262018;
21574
- --text: #ece3d8;
21575
- --text-mid: #b0a494;
21576
- --text-soft: #736455;
21577
- --accent: #c4a255;
21578
- --accent-w: #e8b865;
21579
- --mono: 'Cascadia Code','Fira Code','JetBrains Mono','Consolas',monospace;
21580
- --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
21581
- --frozen: #5b82cc; --active: #4aad72; --draft: #c4a255; --deprecated: #cc5b5b;
21935
+ --bg: #0f0d0b; --bg-sidebar: #0b0a08; --bg-card: #181512; --bg-hover: #1e1a16; --bg-active: #231e17;
21936
+ --border: #262018; --border-soft: #1e1a14; --text: #ece3d8; --text-mid: #b0a494; --text-soft: #736455;
21937
+ --accent: #c4a255; --mono: 'Cascadia Code','Fira Code','Consolas',monospace; --sans: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
21938
+ --status-frozen: #5b82cc; --status-active: #4aad72; --status-draft: #c4a255; --status-deprecated: #cc5b5b;
21582
21939
  }
21583
21940
  html { scroll-behavior: smooth; }
21584
- body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6; min-height: 100vh; }
21585
-
21586
- /* ── Topbar ── */
21587
- .topbar { height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 28px; background: #0b0a08; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
21941
+ body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
21942
+ .shell { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
21943
+ .topbar { flex-shrink: 0; height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 20px; background: var(--bg-sidebar); border-bottom: 1px solid var(--border); }
21588
21944
  .topbar-brand { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
21589
- .topbar-sep { color: var(--border); font-size: 18px; line-height: 1; }
21945
+ .topbar-sep { color: var(--border); font-size: 18px; }
21590
21946
  .topbar-title { font-size: 13px; color: var(--text-mid); }
21591
- .topbar-date { margin-left: auto; font-family: var(--mono); font-size: 10px; color: var(--text-soft); }
21592
-
21593
- /* ── Layout ── */
21594
- .page { max-width: 880px; margin: 0 auto; padding: 56px 28px 100px; }
21595
-
21596
- /* ── Hero ── */
21597
- .hero { margin-bottom: 48px; }
21598
- .hero-eyebrow { font-family: var(--mono); font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--accent); margin-bottom: 12px; }
21599
- .hero-title { font-size: 40px; font-weight: 800; color: var(--text); letter-spacing: -0.025em; line-height: 1.1; margin-bottom: 10px; }
21600
- .hero-sub { font-size: 15px; color: var(--text-mid); max-width: 520px; line-height: 1.7; margin-bottom: 28px; }
21601
-
21602
- /* ── Stats row ── */
21603
- .stats-row { display: flex; flex-wrap: wrap; gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 8px; }
21604
- .stat { flex: 1; min-width: 90px; padding: 14px 20px; background: var(--bg-card); }
21947
+ .topbar-meta { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
21948
+ .body-row { display: flex; flex: 1; min-height: 0; }
21949
+ .sidebar { width: 220px; flex-shrink: 0; background: var(--bg-sidebar); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
21950
+ .sidebar-header { padding: 14px 16px 10px; border-bottom: 1px solid var(--border); }
21951
+ .sidebar-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-soft); }
21952
+ .nav-tree { flex: 1; overflow-y: auto; padding: 8px 0 32px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
21953
+ .nav-item { display: flex; align-items: center; justify-content: space-between; padding: 7px 14px; font-size: 12px; color: var(--text-mid); cursor: pointer; text-decoration: none; border-left: 2px solid transparent; transition: background 0.1s; }
21954
+ .nav-item:hover { background: var(--bg-hover); color: var(--text); }
21955
+ .nav-item.active { background: var(--bg-active); border-left-color: var(--accent); color: var(--text); }
21956
+ .nav-count { font-family: var(--mono); font-size: 10px; color: var(--text-soft); background: var(--bg-card); padding: 1px 6px; border-radius: 999px; border: 1px solid var(--border); }
21957
+ .content { flex: 1; min-width: 0; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
21958
+ .month-section { max-width: 760px; margin: 0 auto; padding: 40px 40px 80px; }
21959
+ .month-section.hidden { display: none; }
21960
+ .month-header { display: flex; align-items: baseline; gap: 12px; margin-bottom: 28px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
21961
+ .month-title { font-size: 22px; font-weight: 800; color: var(--text); letter-spacing: -0.01em; }
21962
+ .month-count { font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
21963
+ .rev-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 16px 20px; margin-bottom: 12px; }
21964
+ .rev-card-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 10px; }
21965
+ .rev-feature { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
21966
+ .rev-key { font-family: var(--mono); font-size: 10px; color: var(--text-soft); }
21967
+ .rev-title { font-size: 13px; font-weight: 600; color: var(--text); }
21968
+ .rev-domain { font-family: var(--mono); font-size: 10px; color: var(--text-soft); padding: 1px 7px; background: var(--bg); border: 1px solid var(--border); border-radius: 999px; }
21969
+ .rev-status { font-family: var(--mono); font-size: 10px; }
21970
+ .rev-meta { text-align: right; flex-shrink: 0; }
21971
+ .rev-date { font-family: var(--mono); font-size: 11px; color: var(--text-soft); display: block; }
21972
+ .rev-author { font-family: var(--mono); font-size: 10px; color: var(--accent); }
21973
+ .rev-reason { font-size: 13px; color: var(--text-mid); line-height: 1.65; margin-bottom: 10px; }
21974
+ .rev-fields { display: flex; gap: 6px; flex-wrap: wrap; }
21975
+ .rev-field { font-family: var(--mono); font-size: 10px; color: var(--text-soft); background: var(--bg); border: 1px solid var(--border); border-radius: 3px; padding: 1px 7px; }
21976
+ .empty-state { text-align: center; padding: 80px 40px; color: var(--text-soft); }
21977
+ .empty-title { font-size: 18px; color: var(--text-mid); margin-bottom: 8px; }
21978
+ </style>
21979
+ </head>
21980
+ <body>
21981
+ <div class="shell">
21982
+ <div class="topbar">
21983
+ <span class="topbar-brand">lac·changelog</span>
21984
+ <span class="topbar-sep">/</span>
21985
+ <span class="topbar-title">${esc(projectName)}</span>
21986
+ <span class="topbar-meta">${entries.length} revision${entries.length !== 1 ? "s" : ""}${sinceNote}</span>
21987
+ </div>
21988
+ <div class="body-row">
21989
+ <div class="sidebar">
21990
+ <div class="sidebar-header"><div class="sidebar-label">By Month</div></div>
21991
+ <nav class="nav-tree">${navHtml}</nav>
21992
+ </div>
21993
+ <main class="content" id="content">
21994
+ ${entries.length === 0 ? `<div class="empty-state"><div class="empty-title">No revisions found</div><p>Add revision entries to your feature.jsons${since ? ` after ${since}` : ""}.</p></div>` : sectionsHtml}
21995
+ </main>
21996
+ </div>
21997
+ </div>
21998
+ <script>
21999
+ function showMonth(month) {
22000
+ document.querySelectorAll('.month-section').forEach(s => s.classList.add('hidden'))
22001
+ const sec = document.getElementById(month)
22002
+ if (sec) { sec.classList.remove('hidden'); document.getElementById('content').scrollTop = 0 }
22003
+ document.querySelectorAll('.nav-item').forEach(el => el.classList.toggle('active', el.dataset.month === month))
22004
+ history.replaceState(null, '', '#' + month)
22005
+ }
22006
+ document.querySelectorAll('.nav-item').forEach(el => {
22007
+ el.addEventListener('click', e => { e.preventDefault(); showMonth(el.dataset.month) })
22008
+ })
22009
+ const hash = window.location.hash.slice(1)
22010
+ if (hash && document.getElementById(hash)) showMonth(hash)
22011
+ <\/script>
22012
+ </body>
22013
+ </html>`;
22014
+ }
22015
+ //#endregion
22016
+ //#region src/lib/releaseNotesGenerator.ts
22017
+ /**
22018
+ * generateReleaseNotes — user-facing release notes filtered by date or releaseVersion.
22019
+ *
22020
+ * Features included: those that transitioned to "frozen" after `since` date,
22021
+ * OR those whose `releaseVersion` matches the `release` option.
22022
+ * Falls back to all frozen features if neither filter is provided.
22023
+ *
22024
+ * Output: lac-release-notes.html — communication-ready, PM/user tone.
22025
+ */
22026
+ function generateReleaseNotes(features, projectName, opts) {
22027
+ function esc(s) {
22028
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
22029
+ }
22030
+ function mdToHtml(raw) {
22031
+ function inline(s) {
22032
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/\*([^*\n]+?)\*/g, "<em>$1</em>").replace(/`([^`]+)`/g, "<code>$1</code>");
22033
+ }
22034
+ return raw.split(/\n{2,}/).map((block) => {
22035
+ const lines = block.split("\n");
22036
+ if (lines.every((l) => /^\s*[-*]\s/.test(l))) return `<ul>${lines.map((l) => `<li>${inline(l.replace(/^\s*[-*]\s/, ""))}</li>`).join("")}</ul>`;
22037
+ return `<p>${lines.map((l) => inline(l.trim())).filter(Boolean).join(" ")}</p>`;
22038
+ }).join("\n");
22039
+ }
22040
+ let releaseFeatures = features.filter((f) => {
22041
+ const feat = f;
22042
+ if (opts.release && feat["releaseVersion"] === opts.release) return true;
22043
+ if (opts.since) {
22044
+ const history = feat["statusHistory"];
22045
+ if (history) return history.some((t) => t.to === "frozen" && t.date >= opts.since);
22046
+ }
22047
+ if (!opts.since && !opts.release) return f.status === "frozen";
22048
+ return false;
22049
+ });
22050
+ releaseFeatures = releaseFeatures.sort((a, b) => (a.priority ?? 99) - (b.priority ?? 99));
22051
+ const domainOrder = [];
22052
+ const seen = /* @__PURE__ */ new Set();
22053
+ for (const f of releaseFeatures) {
22054
+ const d = f.domain ?? "other";
22055
+ if (!seen.has(d)) {
22056
+ seen.add(d);
22057
+ domainOrder.push(d);
22058
+ }
22059
+ }
22060
+ const PALETTE = [
22061
+ "#c4a255",
22062
+ "#e8674a",
22063
+ "#4aad72",
22064
+ "#5b82cc",
22065
+ "#b87fda",
22066
+ "#4ab8cc",
22067
+ "#cc5b5b",
22068
+ "#a2cc4a"
22069
+ ];
22070
+ const domainColor = {};
22071
+ domainOrder.forEach((d, i) => {
22072
+ domainColor[d] = PALETTE[i % PALETTE.length];
22073
+ });
22074
+ const filterLabel = opts.release ? `v${opts.release}` : opts.since ? `since ${opts.since}` : "all frozen features";
22075
+ function renderFeatureCard(f) {
22076
+ const feat = f;
22077
+ const domain = f.domain ?? "other";
22078
+ const color = domainColor[domain] ?? "#c4a255";
22079
+ const userGuide = typeof feat["userGuide"] === "string" ? feat["userGuide"] : "";
22080
+ const pmSummary = typeof feat["pmSummary"] === "string" ? feat["pmSummary"] : "";
22081
+ const limitations = feat["knownLimitations"] ?? [];
22082
+ const releaseVer = typeof feat["releaseVersion"] === "string" ? feat["releaseVersion"] : "";
22083
+ return `<div class="feature-card" id="${esc(f.featureKey)}">
22084
+ <div class="card-domain-eyebrow" style="color:${color}">${esc(domain.replace(/-/g, " "))}${releaseVer ? ` · v${esc(releaseVer)}` : ""}</div>
22085
+ <div class="card-title">${esc(f.title)}</div>
22086
+ ${pmSummary ? `<div class="card-pm-summary">${esc(pmSummary)}</div>` : ""}
22087
+ ${userGuide ? `<div class="card-guide-block"><div class="card-guide-label">How to use</div><div class="card-guide-text">${mdToHtml(userGuide)}</div></div>` : ""}
22088
+ ${limitations.length > 0 ? `<div class="card-limits"><div class="card-limits-label">Known limitations</div><ul class="card-limits-list">${limitations.map((l) => `<li>${esc(l)}</li>`).join("")}</ul></div>` : ""}
22089
+ </div>`;
22090
+ }
22091
+ const domainSectionsHtml = domainOrder.map((domain) => {
22092
+ const domFeatures = releaseFeatures.filter((f) => (f.domain ?? "other") === domain);
22093
+ return `<div class="domain-section">
22094
+ <div class="domain-header">
22095
+ <div class="domain-pip" style="background:${domainColor[domain]}"></div>
22096
+ <div class="domain-title">${esc(domain.replace(/-/g, " "))}</div>
22097
+ <div class="domain-count">${domFeatures.length} feature${domFeatures.length !== 1 ? "s" : ""}</div>
22098
+ </div>
22099
+ ${domFeatures.map(renderFeatureCard).join("")}
22100
+ </div>`;
22101
+ }).join("");
22102
+ return `<!DOCTYPE html>
22103
+ <html lang="en">
22104
+ <head>
22105
+ <meta charset="UTF-8">
22106
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
22107
+ <title>${esc(projectName)} — Release Notes</title>
22108
+ <style>
22109
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
22110
+ :root {
22111
+ --bg: #0f0d0b; --bg-card: #181512; --bg-hover: #1e1a16; --border: #262018; --text: #ece3d8; --text-mid: #b0a494; --text-soft: #736455;
22112
+ --accent: #c4a255; --mono: 'Cascadia Code','Fira Code','Consolas',monospace; --sans: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
22113
+ }
22114
+ html { scroll-behavior: smooth; }
22115
+ body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6; }
22116
+ .topbar { height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 20px; background: #0b0a08; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
22117
+ .topbar-brand { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
22118
+ .topbar-sep { color: var(--border); font-size: 18px; }
22119
+ .topbar-title { font-size: 13px; color: var(--text-mid); }
22120
+ .topbar-filter { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--text-soft); padding: 3px 10px; border: 1px solid var(--border); border-radius: 999px; background: var(--bg-card); }
22121
+ .main { max-width: 820px; margin: 0 auto; padding: 56px 40px 100px; }
22122
+ .page-eyebrow { font-family: var(--mono); font-size: 9px; letter-spacing: 0.2em; text-transform: uppercase; color: var(--accent); margin-bottom: 14px; }
22123
+ .page-title { font-size: 36px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 10px; }
22124
+ .page-sub { font-size: 15px; color: var(--text-mid); margin-bottom: 48px; max-width: 560px; }
22125
+ .stats-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 48px; }
22126
+ .stat-card { padding: 14px 18px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; min-width: 100px; }
22127
+ .stat-num { font-family: var(--mono); font-size: 24px; font-weight: 700; color: var(--accent); }
22128
+ .stat-lbl { font-size: 11px; color: var(--text-soft); margin-top: 4px; }
22129
+ .domain-section { margin-bottom: 56px; }
22130
+ .domain-header { display: flex; align-items: center; gap: 10px; margin-bottom: 20px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
22131
+ .domain-pip { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
22132
+ .domain-title { font-family: var(--mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text); }
22133
+ .domain-count { font-family: var(--mono); font-size: 10px; color: var(--text-soft); margin-left: auto; }
22134
+ .feature-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 10px; padding: 24px 28px; margin-bottom: 16px; }
22135
+ .card-domain-eyebrow { font-family: var(--mono); font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase; margin-bottom: 8px; }
22136
+ .card-title { font-size: 20px; font-weight: 800; color: var(--text); letter-spacing: -0.01em; margin-bottom: 10px; }
22137
+ .card-pm-summary { font-size: 14px; color: var(--text-mid); line-height: 1.7; margin-bottom: 16px; font-style: italic; }
22138
+ .card-guide-block { margin-bottom: 16px; }
22139
+ .card-guide-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent); margin-bottom: 10px; }
22140
+ .card-guide-text { font-size: 14px; color: var(--text-mid); line-height: 1.8; }
22141
+ .card-guide-text p { margin-bottom: 0.75em; }
22142
+ .card-guide-text p:last-child { margin-bottom: 0; }
22143
+ .card-guide-text ul { margin: 0.4em 0 0.8em 1.4em; }
22144
+ .card-guide-text li { margin-bottom: 0.3em; }
22145
+ .card-guide-text strong { color: var(--text); font-weight: 600; }
22146
+ .card-guide-text code { font-family: var(--mono); font-size: 12px; background: var(--bg-hover); border: 1px solid var(--border); border-radius: 3px; padding: 1px 5px; }
22147
+ .card-limits { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); }
22148
+ .card-limits-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-soft); margin-bottom: 8px; }
22149
+ .card-limits-list { list-style: none; padding: 0; }
22150
+ .card-limits-list li { font-size: 12px; color: var(--text-soft); line-height: 1.6; margin-bottom: 4px; padding-left: 14px; position: relative; }
22151
+ .card-limits-list li::before { content: '—'; position: absolute; left: 0; opacity: 0.4; }
22152
+ .empty-state { text-align: center; padding: 80px 40px; color: var(--text-soft); }
22153
+ .empty-title { font-size: 20px; color: var(--text-mid); margin-bottom: 10px; font-weight: 700; }
22154
+ </style>
22155
+ </head>
22156
+ <body>
22157
+ <div class="topbar">
22158
+ <span class="topbar-brand">lac·release-notes</span>
22159
+ <span class="topbar-sep">/</span>
22160
+ <span class="topbar-title">${esc(projectName)}</span>
22161
+ <span class="topbar-filter">${esc(filterLabel)}</span>
22162
+ </div>
22163
+ <main class="main">
22164
+ <div class="page-eyebrow">release notes</div>
22165
+ <div class="page-title">${esc(projectName)}</div>
22166
+ <div class="page-sub">What shipped — ${esc(filterLabel)}. ${releaseFeatures.length} feature${releaseFeatures.length !== 1 ? "s" : ""} across ${domainOrder.length} domain${domainOrder.length !== 1 ? "s" : ""}.</div>
22167
+ <div class="stats-row">
22168
+ <div class="stat-card"><div class="stat-num">${releaseFeatures.length}</div><div class="stat-lbl">features shipped</div></div>
22169
+ <div class="stat-card"><div class="stat-num">${domainOrder.length}</div><div class="stat-lbl">domains</div></div>
22170
+ <div class="stat-card"><div class="stat-num">${releaseFeatures.filter((f) => f["userGuide"]).length}</div><div class="stat-lbl">with user guides</div></div>
22171
+ </div>
22172
+ ${releaseFeatures.length === 0 ? `<div class="empty-state"><div class="empty-title">No features matched the filter</div><p>${opts.since ? `No features frozen after ${opts.since}.` : opts.release ? `No features with releaseVersion "${opts.release}".` : "No frozen features found."}</p></div>` : domainSectionsHtml}
22173
+ </main>
22174
+ </body>
22175
+ </html>`;
22176
+ }
22177
+ //#endregion
22178
+ //#region src/lib/sprintGenerator.ts
22179
+ /**
22180
+ * generateSprint — compact sprint planning view.
22181
+ *
22182
+ * Shows draft + active features sorted by priority.
22183
+ * Summary density: title, status badge, priority, problem (first sentence), successCriteria, tags.
22184
+ * Designed to be scannable in a standup or sprint planning meeting.
22185
+ *
22186
+ * Output: lac-sprint.html
22187
+ */
22188
+ function generateSprint(features, projectName) {
22189
+ function esc(s) {
22190
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
22191
+ }
22192
+ const sorted = [...features].sort((a, b) => {
22193
+ if (a.status !== b.status) return a.status === "active" ? -1 : 1;
22194
+ return (a.priority ?? 99) - (b.priority ?? 99);
22195
+ });
22196
+ const activeCount = sorted.filter((f) => f.status === "active").length;
22197
+ const draftCount = sorted.filter((f) => f.status === "draft").length;
22198
+ const domains = [...new Set(sorted.map((f) => f.domain).filter(Boolean))];
22199
+ function priorityLabel(p) {
22200
+ if (!p) return "";
22201
+ const color = {
22202
+ 1: "#cc5b5b",
22203
+ 2: "#e8674a",
22204
+ 3: "#c4a255",
22205
+ 4: "#4aad72",
22206
+ 5: "#5b82cc"
22207
+ }[p] ?? "#736455";
22208
+ return `<span class="priority-badge" style="background:${color}20;color:${color};border-color:${color}40">P${p}</span>`;
22209
+ }
22210
+ function firstSentence(s) {
22211
+ const match = s.match(/^[^.!?]+[.!?]/);
22212
+ return match ? match[0] : s.length > 120 ? s.slice(0, 120) + "…" : s;
22213
+ }
22214
+ const activeFeatures = sorted.filter((f) => f.status === "active");
22215
+ const draftFeatures = sorted.filter((f) => f.status === "draft");
22216
+ function renderCard(f) {
22217
+ const tags = (f.tags ?? []).slice(0, 4).map((t) => `<span class="tag">${esc(t)}</span>`).join("");
22218
+ const successCriteria = f.successCriteria ?? "";
22219
+ const domain = f.domain ?? "";
22220
+ return `<div class="sprint-card">
22221
+ <div class="card-top">
22222
+ <div class="card-header">
22223
+ ${f.priority ? priorityLabel(f.priority) : ""}
22224
+ <span class="status-dot ${esc(f.status)}"></span>
22225
+ <span class="card-key">${esc(f.featureKey)}</span>
22226
+ </div>
22227
+ <div class="card-title">${esc(f.title)}</div>
22228
+ ${domain ? `<div class="card-domain">${esc(domain.replace(/-/g, " "))}</div>` : ""}
22229
+ </div>
22230
+ <div class="card-problem">${esc(firstSentence(f.problem))}</div>
22231
+ ${successCriteria ? `<div class="card-criteria"><span class="criteria-label">Done when:</span> ${esc(firstSentence(successCriteria))}</div>` : ""}
22232
+ ${tags ? `<div class="card-tags">${tags}</div>` : ""}
22233
+ </div>`;
22234
+ }
22235
+ function renderGroup(title, groupFeatures, statusClass) {
22236
+ if (groupFeatures.length === 0) return "";
22237
+ return `<section class="group-section">
22238
+ <div class="group-header">
22239
+ <div class="group-dot ${statusClass}"></div>
22240
+ <div class="group-title">${esc(title)}</div>
22241
+ <div class="group-count">${groupFeatures.length}</div>
22242
+ </div>
22243
+ <div class="cards-grid">
22244
+ ${groupFeatures.map(renderCard).join("")}
22245
+ </div>
22246
+ </section>`;
22247
+ }
22248
+ return `<!DOCTYPE html>
22249
+ <html lang="en">
22250
+ <head>
22251
+ <meta charset="UTF-8">
22252
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
22253
+ <title>${esc(projectName)} — Sprint Board</title>
22254
+ <style>
22255
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
22256
+ :root {
22257
+ --bg: #0f0d0b; --bg-card: #181512; --bg-hover: #1e1a16; --border: #262018; --text: #ece3d8; --text-mid: #b0a494; --text-soft: #736455;
22258
+ --accent: #c4a255; --mono: 'Cascadia Code','Fira Code','Consolas',monospace; --sans: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
22259
+ --active: #4aad72; --draft: #c4a255;
22260
+ }
22261
+ html { scroll-behavior: smooth; }
22262
+ body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6; }
22263
+ .topbar { height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 24px; background: #0b0a08; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
22264
+ .topbar-brand { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
22265
+ .topbar-sep { color: var(--border); font-size: 18px; }
22266
+ .topbar-title { font-size: 13px; color: var(--text-mid); }
22267
+ .topbar-stats { margin-left: auto; display: flex; gap: 16px; }
22268
+ .stat { font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
22269
+ .stat strong { color: var(--text-mid); }
22270
+ .main { max-width: 1100px; margin: 0 auto; padding: 40px 32px 80px; }
22271
+ .page-title { font-size: 26px; font-weight: 800; color: var(--text); letter-spacing: -0.015em; margin-bottom: 4px; }
22272
+ .page-sub { font-size: 13px; color: var(--text-soft); margin-bottom: 40px; }
22273
+ .group-section { margin-bottom: 48px; }
22274
+ .group-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
22275
+ .group-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
22276
+ .group-dot.active { background: var(--active); }
22277
+ .group-dot.draft { background: var(--draft); }
22278
+ .group-title { font-family: var(--mono); font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text); }
22279
+ .group-count { font-family: var(--mono); font-size: 10px; color: var(--text-soft); margin-left: auto; }
22280
+ .cards-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
22281
+ .sprint-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 16px 18px; transition: border-color 0.15s; }
22282
+ .sprint-card:hover { border-color: var(--text-soft); }
22283
+ .card-top { margin-bottom: 10px; }
22284
+ .card-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
22285
+ .status-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
22286
+ .status-dot.active { background: var(--active); }
22287
+ .status-dot.draft { background: var(--draft); }
22288
+ .card-key { font-family: var(--mono); font-size: 10px; color: var(--text-soft); }
22289
+ .priority-badge { font-family: var(--mono); font-size: 9px; padding: 1px 6px; border-radius: 3px; border: 1px solid; letter-spacing: 0.05em; }
22290
+ .card-title { font-size: 13px; font-weight: 700; color: var(--text); line-height: 1.35; }
22291
+ .card-domain { font-family: var(--mono); font-size: 10px; color: var(--text-soft); margin-top: 3px; }
22292
+ .card-problem { font-size: 12px; color: var(--text-mid); line-height: 1.6; margin-bottom: 8px; }
22293
+ .card-criteria { font-size: 11px; color: var(--text-soft); line-height: 1.5; margin-bottom: 8px; }
22294
+ .criteria-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-soft); }
22295
+ .card-tags { display: flex; gap: 4px; flex-wrap: wrap; }
22296
+ .tag { font-family: var(--mono); font-size: 9px; color: var(--text-soft); background: var(--bg); border: 1px solid var(--border); border-radius: 999px; padding: 1px 7px; }
22297
+ .empty-state { text-align: center; padding: 80px 40px; color: var(--text-soft); }
22298
+ </style>
22299
+ </head>
22300
+ <body>
22301
+ <div class="topbar">
22302
+ <span class="topbar-brand">lac·sprint</span>
22303
+ <span class="topbar-sep">/</span>
22304
+ <span class="topbar-title">${esc(projectName)}</span>
22305
+ <div class="topbar-stats">
22306
+ <span class="stat"><strong>${activeCount}</strong> active</span>
22307
+ <span class="stat"><strong>${draftCount}</strong> draft</span>
22308
+ <span class="stat"><strong>${domains.length}</strong> domains</span>
22309
+ </div>
22310
+ </div>
22311
+ <main class="main">
22312
+ <div class="page-title">${esc(projectName)} — Sprint</div>
22313
+ <div class="page-sub">${sorted.length} features in flight · sorted by priority</div>
22314
+ ${sorted.length === 0 ? `<div class="empty-state">No active or draft features found.</div>` : renderGroup("Active", activeFeatures, "active") + renderGroup("Draft", draftFeatures, "draft")}
22315
+ </main>
22316
+ </body>
22317
+ </html>`;
22318
+ }
22319
+ //#endregion
22320
+ //#region src/lib/apiSurfaceGenerator.ts
22321
+ /**
22322
+ * generateApiSurface — aggregates all publicInterface[] entries across the workspace.
22323
+ *
22324
+ * Groups by interface type (React Components, Hooks, Contexts, Services, Functions, Other).
22325
+ * Full-text search. Each entry links back to the feature wiki.
22326
+ *
22327
+ * Output: lac-api-surface.html
22328
+ */
22329
+ function generateApiSurface(features, projectName) {
22330
+ function esc(s) {
22331
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
22332
+ }
22333
+ const entries = [];
22334
+ for (const f of features) {
22335
+ const iface = f["publicInterface"];
22336
+ if (!iface || iface.length === 0) continue;
22337
+ for (const e of iface) entries.push({
22338
+ name: e.name,
22339
+ type: e.type,
22340
+ description: e.description ?? "",
22341
+ featureKey: f.featureKey,
22342
+ featureTitle: f.title,
22343
+ featureDomain: f.domain ?? "misc",
22344
+ componentFile: typeof f["componentFile"] === "string" ? f["componentFile"] : void 0
22345
+ });
22346
+ }
22347
+ function classifyType(t) {
22348
+ const lower = t.toLowerCase();
22349
+ if (lower.includes("react component") || lower.includes("component") || lower.includes("view") || lower.includes("screen")) return "Components";
22350
+ if (lower.includes("hook") || lower.startsWith("use")) return "Hooks";
22351
+ if (lower.includes("context") || lower.includes("provider")) return "Contexts";
22352
+ if (lower.includes("service") || lower.includes("class") || lower.includes("manager")) return "Services";
22353
+ if (lower.includes("function") || lower.includes("util") || lower.includes("helper")) return "Functions";
22354
+ if (lower.includes("type") || lower.includes("interface") || lower.includes("enum")) return "Types";
22355
+ return "Other";
22356
+ }
22357
+ const groupOrder = [
22358
+ "Components",
22359
+ "Hooks",
22360
+ "Contexts",
22361
+ "Services",
22362
+ "Functions",
22363
+ "Types",
22364
+ "Other"
22365
+ ];
22366
+ const groups = /* @__PURE__ */ new Map();
22367
+ for (const e of entries) {
22368
+ const g = classifyType(e.type);
22369
+ const list = groups.get(g) ?? [];
22370
+ list.push(e);
22371
+ groups.set(g, list);
22372
+ }
22373
+ const presentGroups = groupOrder.filter((g) => groups.has(g));
22374
+ const groupIcons = {
22375
+ Components: "🧩",
22376
+ Hooks: "🪝",
22377
+ Contexts: "🔗",
22378
+ Services: "⚙️",
22379
+ Functions: "𝑓",
22380
+ Types: "📐",
22381
+ Other: "◻️"
22382
+ };
22383
+ const navHtml = presentGroups.map((g) => `<a class="nav-item" data-group="${esc(g)}" href="#group-${esc(g)}">${groupIcons[g] ?? ""} ${esc(g)} <span class="nav-count">${groups.get(g).length}</span></a>`).join("\n");
22384
+ function renderEntry(e) {
22385
+ return `<div class="api-entry" data-search="${esc(`${e.name} ${e.type} ${e.description} ${e.featureTitle} ${e.featureDomain}`).toLowerCase()}">
22386
+ <div class="entry-top">
22387
+ <div class="entry-name"><code class="entry-code">${esc(e.name)}</code></div>
22388
+ <div class="entry-type">${esc(e.type)}</div>
22389
+ </div>
22390
+ ${e.description ? `<div class="entry-desc">${esc(e.description)}</div>` : ""}
22391
+ <div class="entry-source">
22392
+ <a class="entry-feature-link" href="./lac-wiki.html#${esc(e.featureKey)}">${esc(e.featureTitle)}</a>
22393
+ <span class="entry-domain">${esc(e.featureDomain.replace(/-/g, " "))}</span>
22394
+ ${e.componentFile ? `<span class="entry-file">${esc(e.componentFile.split(",")[0]?.trim() ?? "")}</span>` : ""}
22395
+ </div>
22396
+ </div>`;
22397
+ }
22398
+ const groupSectionsHtml = presentGroups.map((g) => `<section id="group-${esc(g)}" class="group-section">
22399
+ <div class="group-header">
22400
+ <span class="group-icon">${groupIcons[g] ?? ""}</span>
22401
+ <span class="group-title">${esc(g)}</span>
22402
+ <span class="group-count">${groups.get(g).length}</span>
22403
+ </div>
22404
+ <div class="entries-list">
22405
+ ${groups.get(g).map(renderEntry).join("")}
22406
+ </div>
22407
+ </section>`).join("");
22408
+ const searchData = JSON.stringify(entries.map((e) => ({
22409
+ name: e.name,
22410
+ type: e.type,
22411
+ description: e.description,
22412
+ featureTitle: e.featureTitle,
22413
+ featureDomain: e.featureDomain,
22414
+ featureKey: e.featureKey,
22415
+ group: classifyType(e.type)
22416
+ })));
22417
+ return `<!DOCTYPE html>
22418
+ <html lang="en">
22419
+ <head>
22420
+ <meta charset="UTF-8">
22421
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
22422
+ <title>${esc(projectName)} — API Surface</title>
22423
+ <style>
22424
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
22425
+ :root {
22426
+ --bg: #0f0d0b; --bg-sidebar: #0b0a08; --bg-card: #181512; --bg-hover: #1e1a16; --bg-active: #231e17;
22427
+ --border: #262018; --border-soft: #1e1a14; --text: #ece3d8; --text-mid: #b0a494; --text-soft: #736455;
22428
+ --accent: #c4a255; --mono: 'Cascadia Code','Fira Code','Consolas',monospace; --sans: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
22429
+ }
22430
+ html { scroll-behavior: smooth; }
22431
+ body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
22432
+ .shell { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
22433
+ .topbar { flex-shrink: 0; height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 20px; background: var(--bg-sidebar); border-bottom: 1px solid var(--border); }
22434
+ .topbar-brand { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
22435
+ .topbar-sep { color: var(--border); font-size: 18px; }
22436
+ .topbar-title { font-size: 13px; color: var(--text-mid); }
22437
+ .topbar-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
22438
+ .topbar-count { font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
22439
+ .search-wrap { position: relative; }
22440
+ #gsearch { background: var(--bg-card); border: 1px solid var(--border); border-radius: 5px; padding: 5px 10px 5px 28px; font-family: var(--mono); font-size: 11px; color: var(--text); outline: none; width: 180px; transition: border-color 0.15s, width 0.2s; }
22441
+ #gsearch:focus { border-color: var(--accent); width: 240px; }
22442
+ #gsearch::placeholder { color: var(--text-soft); }
22443
+ .search-icon { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); color: var(--text-soft); pointer-events: none; }
22444
+ .body-row { display: flex; flex: 1; min-height: 0; }
22445
+ .sidebar { width: 200px; flex-shrink: 0; background: var(--bg-sidebar); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow: hidden; }
22446
+ .sidebar-header { padding: 14px 16px 10px; border-bottom: 1px solid var(--border); }
22447
+ .sidebar-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-soft); }
22448
+ .nav-tree { flex: 1; overflow-y: auto; padding: 8px 0; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
22449
+ .nav-item { display: flex; align-items: center; justify-content: space-between; padding: 7px 14px; font-size: 12px; color: var(--text-mid); cursor: pointer; text-decoration: none; border-left: 2px solid transparent; transition: background 0.1s; gap: 6px; }
22450
+ .nav-item:hover { background: var(--bg-hover); color: var(--text); }
22451
+ .nav-item.active { background: var(--bg-active); border-left-color: var(--accent); color: var(--text); }
22452
+ .nav-count { font-family: var(--mono); font-size: 10px; color: var(--text-soft); background: var(--bg-card); padding: 1px 5px; border-radius: 999px; border: 1px solid var(--border); margin-left: auto; }
22453
+ .content { flex: 1; min-width: 0; overflow-y: auto; padding: 32px 40px 80px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
22454
+ .group-section { margin-bottom: 48px; max-width: 800px; }
22455
+ .group-section.hidden { display: none; }
22456
+ .group-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
22457
+ .group-icon { font-size: 16px; }
22458
+ .group-title { font-family: var(--mono); font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text); }
22459
+ .group-count { font-family: var(--mono); font-size: 10px; color: var(--text-soft); margin-left: auto; }
22460
+ .entries-list { display: flex; flex-direction: column; gap: 8px; }
22461
+ .api-entry { background: var(--bg-card); border: 1px solid var(--border); border-radius: 7px; padding: 14px 18px; transition: border-color 0.15s; }
22462
+ .api-entry:hover { border-color: var(--text-soft); }
22463
+ .api-entry.filtered-out { display: none; }
22464
+ .entry-top { display: flex; align-items: baseline; gap: 12px; margin-bottom: 6px; }
22465
+ .entry-name { flex-shrink: 0; }
22466
+ .entry-code { font-family: var(--mono); font-size: 13px; color: var(--accent); background: transparent; }
22467
+ .entry-type { font-family: var(--mono); font-size: 10px; color: var(--text-soft); padding: 1px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 3px; }
22468
+ .entry-desc { font-size: 12px; color: var(--text-mid); line-height: 1.6; margin-bottom: 8px; }
22469
+ .entry-source { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
22470
+ .entry-feature-link { font-family: var(--mono); font-size: 10px; color: var(--accent); text-decoration: none; opacity: 0.7; }
22471
+ .entry-feature-link:hover { opacity: 1; text-decoration: underline; }
22472
+ .entry-domain { font-family: var(--mono); font-size: 10px; color: var(--text-soft); padding: 1px 7px; background: var(--bg); border: 1px solid var(--border); border-radius: 999px; }
22473
+ .entry-file { font-family: var(--mono); font-size: 10px; color: var(--text-soft); }
22474
+ .empty-state { text-align: center; padding: 60px 40px; color: var(--text-soft); }
22475
+ #no-results { display: none; padding: 40px; text-align: center; color: var(--text-soft); font-size: 13px; }
22476
+ </style>
22477
+ </head>
22478
+ <body>
22479
+ <div class="shell">
22480
+ <div class="topbar">
22481
+ <span class="topbar-brand">lac·api-surface</span>
22482
+ <span class="topbar-sep">/</span>
22483
+ <span class="topbar-title">${esc(projectName)}</span>
22484
+ <div class="topbar-right">
22485
+ <span class="topbar-count">${entries.length} exports</span>
22486
+ <div class="search-wrap">
22487
+ <svg class="search-icon" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
22488
+ <input type="text" id="gsearch" placeholder="Search API…" autocomplete="off" spellcheck="false">
22489
+ </div>
22490
+ </div>
22491
+ </div>
22492
+ <div class="body-row">
22493
+ <div class="sidebar">
22494
+ <div class="sidebar-header"><div class="sidebar-label">By Type</div></div>
22495
+ <nav class="nav-tree">
22496
+ <a class="nav-item active" data-group="all" href="#">All <span class="nav-count">${entries.length}</span></a>
22497
+ ${navHtml}
22498
+ </nav>
22499
+ </div>
22500
+ <main class="content" id="content">
22501
+ ${entries.length === 0 ? `<div class="empty-state">No publicInterface entries found. Add publicInterface[] to your feature.jsons.</div>` : groupSectionsHtml}
22502
+ <div id="no-results">No matches found.</div>
22503
+ </main>
22504
+ </div>
22505
+ </div>
22506
+ <script>
22507
+ const SEARCH_DATA = ${searchData};
22508
+ const gsearch = document.getElementById('gsearch');
22509
+ function filterEntries(q) {
22510
+ const lower = q.toLowerCase().trim();
22511
+ let anyVisible = false;
22512
+ document.querySelectorAll('.api-entry').forEach(el => {
22513
+ const searchStr = el.dataset.search ?? '';
22514
+ const match = !lower || searchStr.includes(lower);
22515
+ el.classList.toggle('filtered-out', !match);
22516
+ if (match) anyVisible = true;
22517
+ });
22518
+ document.getElementById('no-results').style.display = (lower && !anyVisible) ? 'block' : 'none';
22519
+ }
22520
+ gsearch.addEventListener('input', () => filterEntries(gsearch.value));
22521
+ document.querySelectorAll('.nav-item').forEach(el => {
22522
+ el.addEventListener('click', e => {
22523
+ e.preventDefault();
22524
+ const group = el.dataset.group;
22525
+ document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
22526
+ el.classList.add('active');
22527
+ if (group === 'all') {
22528
+ document.querySelectorAll('.group-section').forEach(s => s.classList.remove('hidden'));
22529
+ } else {
22530
+ document.querySelectorAll('.group-section').forEach(s => {
22531
+ s.classList.toggle('hidden', s.id !== 'group-' + group);
22532
+ });
22533
+ const target = document.getElementById('group-' + group);
22534
+ if (target) { document.getElementById('content').scrollTop = target.offsetTop - 20; }
22535
+ }
22536
+ });
22537
+ });
22538
+ <\/script>
22539
+ </body>
22540
+ </html>`;
22541
+ }
22542
+ //#endregion
22543
+ //#region src/lib/dependencyMapGenerator.ts
22544
+ /**
22545
+ * generateDependencyMap — visualizes cross-feature runtime dependencies from externalDependencies[].
22546
+ *
22547
+ * Distinct from the lineage graph (which shows parent/child hierarchy).
22548
+ * This shows the RUNTIME dependency web — which features call into other features at runtime.
22549
+ *
22550
+ * Uses a force-directed canvas graph with:
22551
+ * - Node size = number of dependents (high coupling = larger node)
22552
+ * - Node color = domain
22553
+ * - Edge direction = dependency (A → B means A depends on B)
22554
+ * - Red edges = potential cycles
22555
+ * - Detail panel on click
22556
+ *
22557
+ * Output: lac-depmap.html
22558
+ */
22559
+ function generateDependencyMap(features, projectName) {
22560
+ function esc(s) {
22561
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
22562
+ }
22563
+ const nodeMap = /* @__PURE__ */ new Map();
22564
+ for (const f of features) nodeMap.set(f.featureKey, {
22565
+ key: f.featureKey,
22566
+ title: f.title,
22567
+ domain: f.domain ?? "misc",
22568
+ status: f.status,
22569
+ deps: [],
22570
+ dependents: []
22571
+ });
22572
+ for (const f of features) {
22573
+ const externalDeps = f["externalDependencies"];
22574
+ if (!externalDeps || externalDeps.length === 0) continue;
22575
+ const node = nodeMap.get(f.featureKey);
22576
+ for (const dep of externalDeps) if (nodeMap.has(dep)) {
22577
+ node.deps.push(dep);
22578
+ nodeMap.get(dep).dependents.push(f.featureKey);
22579
+ }
22580
+ }
22581
+ const nodes = [...nodeMap.values()];
22582
+ const edges = nodes.flatMap((n) => n.deps.map((dep) => ({
22583
+ from: n.key,
22584
+ to: dep
22585
+ })));
22586
+ function hasCycle(from, to, visited = /* @__PURE__ */ new Set()) {
22587
+ if (from === to) return true;
22588
+ if (visited.has(from)) return false;
22589
+ visited.add(from);
22590
+ return nodeMap.get(from)?.deps.some((dep) => hasCycle(dep, to, new Set(visited))) ?? false;
22591
+ }
22592
+ const cycleEdges = new Set(edges.filter((e) => hasCycle(e.to, e.from)).map((e) => `${e.from}→${e.to}`));
22593
+ const domains = [...new Set(nodes.map((n) => n.domain))];
22594
+ const PALETTE = [
22595
+ "#c4a255",
22596
+ "#e8674a",
22597
+ "#4aad72",
22598
+ "#5b82cc",
22599
+ "#b87fda",
22600
+ "#4ab8cc",
22601
+ "#cc5b5b",
22602
+ "#a2cc4a",
22603
+ "#e8b865",
22604
+ "#736455"
22605
+ ];
22606
+ const domainColor = {};
22607
+ domains.forEach((d, i) => {
22608
+ domainColor[d] = PALETTE[i % PALETTE.length];
22609
+ });
22610
+ const graphData = JSON.stringify({
22611
+ nodes: nodes.map((n) => ({
22612
+ key: n.key,
22613
+ title: n.title,
22614
+ domain: n.domain,
22615
+ status: n.status,
22616
+ depCount: n.deps.length,
22617
+ dependentCount: n.dependents.length,
22618
+ color: domainColor[n.domain] ?? "#736455"
22619
+ })),
22620
+ edges: edges.map((e) => ({
22621
+ from: e.from,
22622
+ to: e.to,
22623
+ isCycle: cycleEdges.has(`${e.from}→${e.to}`)
22624
+ }))
22625
+ });
22626
+ const isolatedCount = nodes.filter((n) => n.deps.length === 0 && n.dependents.length === 0).length;
22627
+ const highCouplingCount = nodes.filter((n) => n.dependents.length >= 3).length;
22628
+ return `<!DOCTYPE html>
22629
+ <html lang="en">
22630
+ <head>
22631
+ <meta charset="UTF-8">
22632
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
22633
+ <title>${esc(projectName)} — Dependency Map</title>
22634
+ <style>
22635
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
22636
+ :root {
22637
+ --bg: #0f0d0b; --bg-card: #181512; --bg-hover: #1e1a16; --border: #262018; --text: #ece3d8; --text-mid: #b0a494; --text-soft: #736455;
22638
+ --accent: #c4a255; --mono: 'Cascadia Code','Fira Code','Consolas',monospace; --sans: -apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif;
22639
+ }
22640
+ body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
22641
+ .topbar { flex-shrink: 0; height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 20px; background: #0b0a08; border-bottom: 1px solid var(--border); }
22642
+ .topbar-brand { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
22643
+ .topbar-sep { color: var(--border); font-size: 18px; }
22644
+ .topbar-title { font-size: 13px; color: var(--text-mid); }
22645
+ .topbar-stats { margin-left: auto; display: flex; gap: 16px; }
22646
+ .topbar-stat { font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
22647
+ .topbar-stat strong { color: var(--text-mid); }
22648
+ .main { display: flex; flex: 1; min-height: 0; position: relative; }
22649
+ canvas { flex: 1; cursor: grab; display: block; }
22650
+ canvas:active { cursor: grabbing; }
22651
+ .legend { position: absolute; top: 16px; left: 16px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; min-width: 180px; }
22652
+ .legend-title { font-family: var(--mono); font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--text-soft); margin-bottom: 10px; }
22653
+ .legend-items { display: flex; flex-direction: column; gap: 6px; }
22654
+ .legend-item { display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-mid); }
22655
+ .legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
22656
+ .legend-line { width: 20px; height: 2px; flex-shrink: 0; }
22657
+ .detail-panel { position: absolute; top: 16px; right: 16px; width: 280px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 18px 20px; display: none; }
22658
+ .detail-panel.visible { display: block; }
22659
+ .detail-key { font-family: var(--mono); font-size: 10px; color: var(--text-soft); margin-bottom: 4px; }
22660
+ .detail-title { font-size: 15px; font-weight: 700; color: var(--text); margin-bottom: 8px; line-height: 1.3; }
22661
+ .detail-domain { font-family: var(--mono); font-size: 10px; color: var(--text-soft); margin-bottom: 12px; }
22662
+ .detail-section { margin-bottom: 10px; }
22663
+ .detail-label { font-family: var(--mono); font-size: 9px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--text-soft); margin-bottom: 4px; }
22664
+ .detail-links { display: flex; flex-direction: column; gap: 3px; }
22665
+ .detail-link { font-family: var(--mono); font-size: 11px; color: var(--accent); opacity: 0.7; cursor: pointer; }
22666
+ .detail-link:hover { opacity: 1; }
22667
+ .detail-close { position: absolute; top: 10px; right: 12px; font-size: 16px; color: var(--text-soft); cursor: pointer; }
22668
+ .detail-close:hover { color: var(--text); }
22669
+ .detail-count { font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
22670
+ .empty-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 12px; color: var(--text-soft); pointer-events: none; }
22671
+ .empty-title { font-size: 18px; color: var(--text-mid); font-weight: 700; }
22672
+ </style>
22673
+ </head>
22674
+ <body>
22675
+ <div class="topbar">
22676
+ <span class="topbar-brand">lac·depmap</span>
22677
+ <span class="topbar-sep">/</span>
22678
+ <span class="topbar-title">${esc(projectName)}</span>
22679
+ <div class="topbar-stats">
22680
+ <span class="topbar-stat"><strong>${nodes.length}</strong> features</span>
22681
+ <span class="topbar-stat"><strong>${edges.length}</strong> dependencies</span>
22682
+ ${highCouplingCount > 0 ? `<span class="topbar-stat" style="color:#cc5b5b"><strong>${highCouplingCount}</strong> high-coupling</span>` : ""}
22683
+ ${isolatedCount > 0 ? `<span class="topbar-stat"><strong>${isolatedCount}</strong> isolated</span>` : ""}
22684
+ </div>
22685
+ </div>
22686
+ <div class="main">
22687
+ <canvas id="canvas"></canvas>
22688
+ <div class="legend">
22689
+ <div class="legend-title">Legend</div>
22690
+ <div class="legend-items">
22691
+ <div class="legend-item"><div class="legend-line" style="background:#5b82cc;height:1.5px"></div> depends on</div>
22692
+ <div class="legend-item"><div class="legend-line" style="background:#cc5b5b;height:2px"></div> cycle risk</div>
22693
+ <div class="legend-item"><span style="font-family:var(--mono);font-size:11px;color:var(--text-soft)">size = dependents</span></div>
22694
+ ${domains.map((d) => `<div class="legend-item"><div class="legend-dot" style="background:${domainColor[d] ?? "#736455"}"></div>${esc(d.replace(/-/g, " "))}</div>`).join("")}
22695
+ </div>
22696
+ </div>
22697
+ <div class="detail-panel" id="detail">
22698
+ <span class="detail-close" onclick="closeDetail()">×</span>
22699
+ <div class="detail-key" id="detail-key"></div>
22700
+ <div class="detail-title" id="detail-title"></div>
22701
+ <div class="detail-domain" id="detail-domain"></div>
22702
+ <div class="detail-section" id="detail-deps-section">
22703
+ <div class="detail-label">Depends on</div>
22704
+ <div class="detail-links" id="detail-deps"></div>
22705
+ </div>
22706
+ <div class="detail-section" id="detail-dependents-section">
22707
+ <div class="detail-label">Depended on by</div>
22708
+ <div class="detail-links" id="detail-dependents"></div>
22709
+ </div>
22710
+ <div class="detail-count" id="detail-isolated"></div>
22711
+ </div>
22712
+ ${edges.length === 0 ? `<div class="empty-overlay"><div class="empty-title">No dependencies mapped</div><p>Add featureKeys to externalDependencies[] in your feature.jsons.</p></div>` : ""}
22713
+ </div>
22714
+ <script>
22715
+ const GRAPH = ${graphData};
22716
+ const canvas = document.getElementById('canvas');
22717
+ const ctx = canvas.getContext('2d');
22718
+ let W, H, dpr, nodes, edges;
22719
+ let pan = { x: 0, y: 0 }, zoom = 1, dragging = false, lastMouse = null, hoveredKey = null, selectedKey = null;
22720
+
22721
+ function resize() {
22722
+ dpr = window.devicePixelRatio || 1;
22723
+ W = canvas.clientWidth; H = canvas.clientHeight;
22724
+ canvas.width = W * dpr; canvas.height = H * dpr;
22725
+ ctx.scale(dpr, dpr);
22726
+ }
22727
+
22728
+ function initNodes() {
22729
+ nodes = GRAPH.nodes.map((n, i) => {
22730
+ const angle = (i / GRAPH.nodes.length) * 2 * Math.PI;
22731
+ const r = Math.min(W, H) * 0.3;
22732
+ return { ...n, x: W/2 + r * Math.cos(angle), y: H/2 + r * Math.sin(angle), vx: 0, vy: 0 };
22733
+ });
22734
+ edges = GRAPH.edges;
22735
+ }
22736
+
22737
+ const nodeByKey = () => new Map(nodes.map(n => [n.key, n]));
22738
+
22739
+ function tick() {
22740
+ const map = nodeByKey();
22741
+ // Repulsion
22742
+ for (let i = 0; i < nodes.length; i++) {
22743
+ for (let j = i + 1; j < nodes.length; j++) {
22744
+ const a = nodes[i], b = nodes[j];
22745
+ const dx = b.x - a.x, dy = b.y - a.y;
22746
+ const dist = Math.sqrt(dx*dx + dy*dy) || 1;
22747
+ const force = 8000 / (dist * dist);
22748
+ const fx = (dx / dist) * force, fy = (dy / dist) * force;
22749
+ a.vx -= fx; a.vy -= fy; b.vx += fx; b.vy += fy;
22750
+ }
22751
+ }
22752
+ // Spring edges
22753
+ for (const e of edges) {
22754
+ const a = map.get(e.from), b = map.get(e.to);
22755
+ if (!a || !b) continue;
22756
+ const dx = b.x - a.x, dy = b.y - a.y;
22757
+ const dist = Math.sqrt(dx*dx + dy*dy) || 1;
22758
+ const target = 120;
22759
+ const force = (dist - target) * 0.04;
22760
+ const fx = (dx / dist) * force, fy = (dy / dist) * force;
22761
+ a.vx += fx; a.vy += fy; b.vx -= fx; b.vy -= fy;
22762
+ }
22763
+ // Gravity to center
22764
+ for (const n of nodes) {
22765
+ n.vx += (W/2 - n.x) * 0.003;
22766
+ n.vy += (H/2 - n.y) * 0.003;
22767
+ }
22768
+ // Apply + dampen
22769
+ for (const n of nodes) {
22770
+ n.x += n.vx * 0.4; n.y += n.vy * 0.4;
22771
+ n.vx *= 0.7; n.vy *= 0.7;
22772
+ }
22773
+ }
22774
+
22775
+ function draw() {
22776
+ ctx.clearRect(0, 0, W, H);
22777
+ ctx.save();
22778
+ ctx.translate(pan.x, pan.y);
22779
+ ctx.scale(zoom, zoom);
22780
+ const map = nodeByKey();
22781
+ // Edges
22782
+ for (const e of edges) {
22783
+ const a = map.get(e.from), b = map.get(e.to);
22784
+ if (!a || !b) continue;
22785
+ const isHighlighted = selectedKey && (e.from === selectedKey || e.to === selectedKey);
22786
+ ctx.globalAlpha = isHighlighted ? 1 : (selectedKey ? 0.15 : 0.5);
22787
+ ctx.strokeStyle = e.isCycle ? '#cc5b5b' : '#5b82cc';
22788
+ ctx.lineWidth = isHighlighted ? 2 : 1;
22789
+ ctx.beginPath();
22790
+ ctx.moveTo(a.x, a.y);
22791
+ // Arrow to B
22792
+ const dx = b.x - a.x, dy = b.y - a.y;
22793
+ const dist = Math.sqrt(dx*dx + dy*dy);
22794
+ const bR = nodeRadius(b);
22795
+ const ex = b.x - (dx/dist) * bR;
22796
+ const ey = b.y - (dy/dist) * bR;
22797
+ ctx.lineTo(ex, ey);
22798
+ ctx.stroke();
22799
+ // Arrowhead
22800
+ const angle = Math.atan2(dy, dx);
22801
+ ctx.fillStyle = e.isCycle ? '#cc5b5b' : '#5b82cc';
22802
+ ctx.beginPath();
22803
+ ctx.moveTo(ex, ey);
22804
+ ctx.lineTo(ex - 8*Math.cos(angle-0.4), ey - 8*Math.sin(angle-0.4));
22805
+ ctx.lineTo(ex - 8*Math.cos(angle+0.4), ey - 8*Math.sin(angle+0.4));
22806
+ ctx.closePath();
22807
+ ctx.fill();
22808
+ }
22809
+ // Nodes
22810
+ for (const n of nodes) {
22811
+ const r = nodeRadius(n);
22812
+ const isHovered = n.key === hoveredKey;
22813
+ const isSelected = n.key === selectedKey;
22814
+ ctx.globalAlpha = selectedKey && !isSelected ? 0.3 : 1;
22815
+ ctx.beginPath();
22816
+ ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
22817
+ ctx.fillStyle = n.color;
22818
+ ctx.globalAlpha *= isHovered ? 1 : 0.85;
22819
+ ctx.fill();
22820
+ if (isSelected || isHovered) {
22821
+ ctx.strokeStyle = '#ece3d8';
22822
+ ctx.lineWidth = 2;
22823
+ ctx.stroke();
22824
+ }
22825
+ ctx.globalAlpha = selectedKey && !isSelected ? 0.3 : 1;
22826
+ ctx.fillStyle = '#ece3d8';
22827
+ ctx.font = \`\${Math.max(9, Math.min(12, r * 0.7))}px monospace\`;
22828
+ ctx.textAlign = 'center';
22829
+ ctx.textBaseline = 'middle';
22830
+ const label = n.title.length > 16 ? n.title.slice(0, 14) + '…' : n.title;
22831
+ ctx.fillText(label, n.x, n.y);
22832
+ }
22833
+ ctx.restore();
22834
+ }
22835
+
22836
+ function nodeRadius(n) {
22837
+ return Math.max(18, 18 + (n.dependentCount ?? 0) * 4);
22838
+ }
22839
+
22840
+ function hitTest(mx, my) {
22841
+ const wx = (mx - pan.x) / zoom, wy = (my - pan.y) / zoom;
22842
+ for (const n of [...nodes].reverse()) {
22843
+ const r = nodeRadius(n);
22844
+ if ((wx-n.x)**2 + (wy-n.y)**2 < r*r) return n;
22845
+ }
22846
+ return null;
22847
+ }
22848
+
22849
+ function showDetail(n) {
22850
+ const map = nodeByKey();
22851
+ selectedKey = n.key;
22852
+ document.getElementById('detail-key').textContent = n.key;
22853
+ document.getElementById('detail-title').textContent = n.title;
22854
+ document.getElementById('detail-domain').textContent = n.domain;
22855
+ const depsEl = document.getElementById('detail-deps');
22856
+ const depsSection = document.getElementById('detail-deps-section');
22857
+ depsEl.innerHTML = (n.depCount > 0) ? GRAPH.nodes.filter(x => {
22858
+ const node = nodes.find(nd => nd.key === n.key);
22859
+ return node && GRAPH.edges.some(e => e.from === n.key && e.to === x.key);
22860
+ }).map(x => \`<span class="detail-link" onclick="showDetail(nodes.find(nd=>nd.key==='\${x.key}'))">\${x.title}</span>\`).join('') : '';
22861
+ depsSection.style.display = n.depCount > 0 ? '' : 'none';
22862
+ const depEl = document.getElementById('detail-dependents');
22863
+ const depSection = document.getElementById('detail-dependents-section');
22864
+ depEl.innerHTML = (n.dependentCount > 0) ? GRAPH.nodes.filter(x => {
22865
+ return GRAPH.edges.some(e => e.from === x.key && e.to === n.key);
22866
+ }).map(x => \`<span class="detail-link" onclick="showDetail(nodes.find(nd=>nd.key==='\${x.key}'))">\${x.title}</span>\`).join('') : '';
22867
+ depSection.style.display = n.dependentCount > 0 ? '' : 'none';
22868
+ const isolatedEl = document.getElementById('detail-isolated');
22869
+ isolatedEl.textContent = (n.depCount === 0 && n.dependentCount === 0) ? 'Isolated — no runtime dependencies' : '';
22870
+ document.getElementById('detail').classList.add('visible');
22871
+ }
22872
+
22873
+ function closeDetail() {
22874
+ selectedKey = null;
22875
+ document.getElementById('detail').classList.remove('visible');
22876
+ }
22877
+
22878
+ window.addEventListener('resize', () => { resize(); });
22879
+ canvas.addEventListener('mousedown', e => { dragging = true; lastMouse = { x: e.clientX, y: e.clientY }; });
22880
+ canvas.addEventListener('mouseup', e => {
22881
+ if (dragging && lastMouse && Math.abs(e.clientX - lastMouse.x) < 3 && Math.abs(e.clientY - lastMouse.y) < 3) {
22882
+ const hit = hitTest(e.clientX - canvas.getBoundingClientRect().left, e.clientY - canvas.getBoundingClientRect().top);
22883
+ if (hit) showDetail(hit); else closeDetail();
22884
+ }
22885
+ dragging = false; lastMouse = null;
22886
+ });
22887
+ canvas.addEventListener('mousemove', e => {
22888
+ const rect = canvas.getBoundingClientRect();
22889
+ if (dragging && lastMouse) {
22890
+ pan.x += e.clientX - lastMouse.x; pan.y += e.clientY - lastMouse.y;
22891
+ lastMouse = { x: e.clientX, y: e.clientY };
22892
+ }
22893
+ const hit = hitTest(e.clientX - rect.left, e.clientY - rect.top);
22894
+ hoveredKey = hit ? hit.key : null;
22895
+ canvas.style.cursor = hit ? 'pointer' : (dragging ? 'grabbing' : 'grab');
22896
+ });
22897
+ canvas.addEventListener('wheel', e => {
22898
+ e.preventDefault();
22899
+ const f = e.deltaY < 0 ? 1.1 : 0.9;
22900
+ const rect = canvas.getBoundingClientRect();
22901
+ const mx = e.clientX - rect.left, my = e.clientY - rect.top;
22902
+ pan.x = mx - (mx - pan.x) * f;
22903
+ pan.y = my - (my - pan.y) * f;
22904
+ zoom *= f;
22905
+ }, { passive: false });
22906
+
22907
+ resize();
22908
+ initNodes();
22909
+ let frame = 0;
22910
+ function loop() {
22911
+ if (frame < 200) tick();
22912
+ frame++;
22913
+ draw();
22914
+ requestAnimationFrame(loop);
22915
+ }
22916
+ loop();
22917
+ <\/script>
22918
+ </body>
22919
+ </html>`;
22920
+ }
22921
+ //#endregion
22922
+ //#region src/lib/hubGenerator.ts
22923
+ /** Canonical ordered entry definitions for all standard LAC outputs. */
22924
+ const ALL_HUB_ENTRIES = [
22925
+ {
22926
+ file: "lac-guide.html",
22927
+ label: "User Guide",
22928
+ description: "How to use every user-facing feature — generated from userGuide fields",
22929
+ icon: "📖",
22930
+ primary: true
22931
+ },
22932
+ {
22933
+ file: "lac-story.html",
22934
+ label: "Product Story",
22935
+ description: "Long-form narrative case study built from feature data",
22936
+ icon: "📰",
22937
+ primary: true
22938
+ },
22939
+ {
22940
+ file: "lac-release-notes.html",
22941
+ label: "Release Notes",
22942
+ description: "User-facing release notes — features that shipped recently",
22943
+ icon: "🚀",
22944
+ primary: true
22945
+ },
22946
+ {
22947
+ file: "lac-sprint.html",
22948
+ label: "Sprint Board",
22949
+ description: "Active + draft features sorted by priority — sprint planning at a glance",
22950
+ icon: "⚡",
22951
+ primary: false
22952
+ },
22953
+ {
22954
+ file: "lac-wiki.html",
22955
+ label: "Feature Wiki",
22956
+ description: "Complete searchable wiki — all fields, all features, sidebar navigation",
22957
+ icon: "🗂️",
22958
+ primary: false
22959
+ },
22960
+ {
22961
+ file: "lac-kanban.html",
22962
+ label: "Kanban Board",
22963
+ description: "Active / Frozen / Draft columns with sortable, filterable cards",
22964
+ icon: "📋",
22965
+ primary: false
22966
+ },
22967
+ {
22968
+ file: "lac-changelog.html",
22969
+ label: "Changelog",
22970
+ description: "Feature revision history grouped by month — all changes across the workspace",
22971
+ icon: "📅",
22972
+ primary: false
22973
+ },
22974
+ {
22975
+ file: "lac-decisions.html",
22976
+ label: "Decision Log",
22977
+ description: "All architectural decisions consolidated and searchable by domain",
22978
+ icon: "⚖️",
22979
+ primary: false
22980
+ },
22981
+ {
22982
+ file: "lac-api-surface.html",
22983
+ label: "API Surface",
22984
+ description: "Aggregated public interface reference — all exported components, hooks, and types",
22985
+ icon: "🔌",
22986
+ primary: false
22987
+ },
22988
+ {
22989
+ file: "lac-depmap.html",
22990
+ label: "Dependency Map",
22991
+ description: "Runtime cross-feature dependency graph from externalDependencies[]",
22992
+ icon: "🕸️",
22993
+ primary: false
22994
+ },
22995
+ {
22996
+ file: "lac-health.html",
22997
+ label: "Health Scorecard",
22998
+ description: "Completeness, coverage, tech-debt score, and field fill rates",
22999
+ icon: "🏥",
23000
+ primary: false
23001
+ },
23002
+ {
23003
+ file: "lac-heatmap.html",
23004
+ label: "Completeness Heatmap",
23005
+ description: "Field x feature completeness grid — quickly spot gaps",
23006
+ icon: "🔥",
23007
+ primary: false
23008
+ },
23009
+ {
23010
+ file: "lac-graph.html",
23011
+ label: "Lineage Graph",
23012
+ description: "Interactive force-directed feature lineage graph",
23013
+ icon: "🌐",
23014
+ primary: false
23015
+ },
23016
+ {
23017
+ file: "lac-print.html",
23018
+ label: "Print",
23019
+ description: "Print-ready A4 document — all features in clean two-column layout",
23020
+ icon: "🖨️",
23021
+ primary: false
23022
+ },
23023
+ {
23024
+ file: "lac-raw.html",
23025
+ label: "Raw Dump",
23026
+ description: "Field-by-field dump of every feature.json with sidebar navigation",
23027
+ icon: "🔩",
23028
+ primary: false
23029
+ }
23030
+ ];
23031
+ function generateHub(projectName, stats, entries, generatedAt = (/* @__PURE__ */ new Date()).toISOString(), prefix) {
23032
+ function esc(s) {
23033
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
23034
+ }
23035
+ const urlPrefix = prefix ? "/" + (/[A-Za-z]:[/\\]/.test(prefix) ? prefix.split(/[/\\]/).filter(Boolean).pop() ?? "" : prefix.replace(/^\/+/, "").replace(/\/+$/, "")) : "";
23036
+ function href(file) {
23037
+ return urlPrefix ? `${urlPrefix}/${file}` : `./${file}`;
23038
+ }
23039
+ const primaryEntries = entries.filter((e) => e.primary);
23040
+ const secondaryEntries = entries.filter((e) => !e.primary);
23041
+ const date = new Date(generatedAt).toLocaleDateString("en-US", {
23042
+ year: "numeric",
23043
+ month: "long",
23044
+ day: "numeric"
23045
+ });
23046
+ function primaryCard(e) {
23047
+ return `
23048
+ <a href="${esc(href(e.file))}" class="primary-card">
23049
+ <div class="primary-card-icon">${e.icon}</div>
23050
+ <div class="primary-card-body">
23051
+ <div class="primary-card-label">${esc(e.label)}</div>
23052
+ <div class="primary-card-desc">${esc(e.description)}</div>
23053
+ </div>
23054
+ <div class="primary-card-arrow">→</div>
23055
+ </a>`;
23056
+ }
23057
+ function secondaryCard(e) {
23058
+ return `
23059
+ <a href="${esc(href(e.file))}" class="secondary-card">
23060
+ <div class="secondary-card-icon">${e.icon}</div>
23061
+ <div class="secondary-card-label">${esc(e.label)}</div>
23062
+ <div class="secondary-card-desc">${esc(e.description)}</div>
23063
+ </a>`;
23064
+ }
23065
+ const domainList = stats.domains.length > 0 ? stats.domains.slice(0, 6).map((d) => `<span class="domain-tag">${esc(d)}</span>`).join("") + (stats.domains.length > 6 ? `<span class="domain-tag">+${stats.domains.length - 6}</span>` : "") : "";
23066
+ return `<!DOCTYPE html>
23067
+ <html lang="en">
23068
+ <head>
23069
+ <meta charset="UTF-8">
23070
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
23071
+ <title>${esc(projectName)} — Life-as-Code Hub</title>
23072
+ <style>
23073
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
23074
+ :root {
23075
+ --bg: #0f0d0b;
23076
+ --bg-card: #181512;
23077
+ --bg-hover: #1e1a16;
23078
+ --border: #262018;
23079
+ --text: #ece3d8;
23080
+ --text-mid: #b0a494;
23081
+ --text-soft: #736455;
23082
+ --accent: #c4a255;
23083
+ --accent-w: #e8b865;
23084
+ --mono: 'Cascadia Code','Fira Code','JetBrains Mono','Consolas',monospace;
23085
+ --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
23086
+ --frozen: #5b82cc; --active: #4aad72; --draft: #c4a255; --deprecated: #cc5b5b;
23087
+ }
23088
+ html { scroll-behavior: smooth; }
23089
+ body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6; min-height: 100vh; }
23090
+
23091
+ /* ── Topbar ── */
23092
+ .topbar { height: 46px; display: flex; align-items: center; gap: 14px; padding: 0 28px; background: #0b0a08; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
23093
+ .topbar-brand { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
23094
+ .topbar-sep { color: var(--border); font-size: 18px; line-height: 1; }
23095
+ .topbar-title { font-size: 13px; color: var(--text-mid); }
23096
+ .topbar-date { margin-left: auto; font-family: var(--mono); font-size: 10px; color: var(--text-soft); }
23097
+
23098
+ /* ── Layout ── */
23099
+ .page { max-width: 880px; margin: 0 auto; padding: 56px 28px 100px; }
23100
+
23101
+ /* ── Hero ── */
23102
+ .hero { margin-bottom: 48px; }
23103
+ .hero-eyebrow { font-family: var(--mono); font-size: 9px; letter-spacing: 0.22em; text-transform: uppercase; color: var(--accent); margin-bottom: 12px; }
23104
+ .hero-title { font-size: 40px; font-weight: 800; color: var(--text); letter-spacing: -0.025em; line-height: 1.1; margin-bottom: 10px; }
23105
+ .hero-sub { font-size: 15px; color: var(--text-mid); max-width: 520px; line-height: 1.7; margin-bottom: 28px; }
23106
+
23107
+ /* ── Stats row ── */
23108
+ .stats-row { display: flex; flex-wrap: wrap; gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 8px; }
23109
+ .stat { flex: 1; min-width: 90px; padding: 14px 20px; background: var(--bg-card); }
21605
23110
  .stat-num { font-family: var(--mono); font-size: 22px; font-weight: 700; line-height: 1; }
21606
23111
  .stat-lbl { font-size: 11px; color: var(--text-soft); margin-top: 3px; }
21607
23112
  .stat-total .stat-num { color: var(--accent); }
@@ -21725,6 +23230,16 @@ function lacGo(file){location.href=location.href.replace(/[^\\/]*$/,'')+file}
21725
23230
  }
21726
23231
  //#endregion
21727
23232
  //#region src/lib/views.ts
23233
+ /** Fields always shown at summary density regardless of view */
23234
+ const SUMMARY_FIELDS = new Set([
23235
+ "featureKey",
23236
+ "title",
23237
+ "status",
23238
+ "domain",
23239
+ "priority",
23240
+ "tags",
23241
+ "problem"
23242
+ ]);
21728
23243
  const VIEW_NAMES = [
21729
23244
  "dev",
21730
23245
  "product",
@@ -21776,10 +23291,13 @@ const VIEWS = {
21776
23291
  "problem",
21777
23292
  "analysis",
21778
23293
  "userGuide",
23294
+ "pmSummary",
21779
23295
  "successCriteria",
23296
+ "acceptanceCriteria",
21780
23297
  "decisions",
21781
23298
  "knownLimitations",
21782
- "tags"
23299
+ "tags",
23300
+ "releaseVersion"
21783
23301
  ])
21784
23302
  },
21785
23303
  dev: {
@@ -21793,8 +23311,11 @@ const VIEWS = {
21793
23311
  "problem",
21794
23312
  "analysis",
21795
23313
  "implementation",
23314
+ "implementationNotes",
21796
23315
  "userGuide",
21797
23316
  "successCriteria",
23317
+ "acceptanceCriteria",
23318
+ "testStrategy",
21798
23319
  "decisions",
21799
23320
  "knownLimitations",
21800
23321
  "tags",
@@ -21819,8 +23340,12 @@ const VIEWS = {
21819
23340
  "problem",
21820
23341
  "analysis",
21821
23342
  "implementation",
23343
+ "implementationNotes",
21822
23344
  "userGuide",
23345
+ "pmSummary",
21823
23346
  "successCriteria",
23347
+ "acceptanceCriteria",
23348
+ "testStrategy",
21824
23349
  "decisions",
21825
23350
  "knownLimitations",
21826
23351
  "tags",
@@ -21834,6 +23359,7 @@ const VIEWS = {
21834
23359
  "externalDependencies",
21835
23360
  "codeSnippets",
21836
23361
  "lastVerifiedDate",
23362
+ "releaseVersion",
21837
23363
  "superseded_by",
21838
23364
  "superseded_from",
21839
23365
  "merged_into",
@@ -21871,6 +23397,74 @@ function applyViewForHtml(feature, view) {
21871
23397
  for (const key of Object.keys(feature)) if (view.fields.has(key) || HTML_NAV_FIELDS.has(key)) result[key] = feature[key];
21872
23398
  return result;
21873
23399
  }
23400
+ /**
23401
+ * Apply density filtering to a feature.
23402
+ * - summary: only SUMMARY_FIELDS (title, status, domain, priority, tags, problem snippet)
23403
+ * - standard: pass through unchanged (generators decide what to show)
23404
+ * - verbose: pass through unchanged but callers should render VERBOSE_EXTRA_FIELDS too
23405
+ *
23406
+ * Returns the filtered feature and the resolved density level.
23407
+ */
23408
+ function applyDensity(feature, density) {
23409
+ if (density === "standard" || density === "verbose") return feature;
23410
+ const result = {};
23411
+ for (const key of Object.keys(feature)) if (SUMMARY_FIELDS.has(key)) if (key === "problem" && typeof feature[key] === "string") {
23412
+ const prob = feature[key];
23413
+ const firstSentence = prob.split(/[.!?]\s/)[0] ?? prob;
23414
+ result[key] = firstSentence.length < prob.length ? firstSentence + "." : prob;
23415
+ } else result[key] = feature[key];
23416
+ return result;
23417
+ }
23418
+ /**
23419
+ * Resolve a view name against both built-in views and custom views from lac.config.json.
23420
+ *
23421
+ * Resolution order:
23422
+ * 1. If `name` matches a key in `customViews` (from lac.config.json), build a ViewConfig from it.
23423
+ * If it has `extends`, merge on top of the built-in base.
23424
+ * 2. Otherwise fall back to VIEWS[name].
23425
+ * 3. If neither matches, return undefined.
23426
+ */
23427
+ function resolveView(name, customViews = {}) {
23428
+ const custom = customViews[name];
23429
+ if (custom) {
23430
+ const base = custom.extends ? VIEWS[custom.extends] : void 0;
23431
+ const baseFields = base ? new Set(base.fields) : /* @__PURE__ */ new Set();
23432
+ const fields = custom.fields ? new Set(custom.fields) : baseFields;
23433
+ for (const f of IDENTITY) fields.add(f);
23434
+ return {
23435
+ name,
23436
+ label: custom.label ?? base?.label ?? name,
23437
+ description: custom.description ?? base?.description ?? `Custom view: ${name}`,
23438
+ fields,
23439
+ density: custom.density,
23440
+ groupBy: custom.groupBy,
23441
+ sortBy: custom.sortBy,
23442
+ filterStatus: custom.filterStatus,
23443
+ sections: custom.sections
23444
+ };
23445
+ }
23446
+ return VIEW_NAMES.includes(name) ? VIEWS[name] : void 0;
23447
+ }
23448
+ /**
23449
+ * Sort and filter a feature list according to a resolved view profile.
23450
+ * This is called before passing features to any generator.
23451
+ */
23452
+ function applyViewTransforms(features, profile) {
23453
+ let result = [...features];
23454
+ if (profile.filterStatus && profile.filterStatus.length > 0) result = result.filter((f) => profile.filterStatus.includes(f["status"]));
23455
+ if (profile.sortBy === "priority") result.sort((a, b) => (a["priority"] ?? 99) - (b["priority"] ?? 99));
23456
+ else if (profile.sortBy === "title") result.sort((a, b) => String(a["title"] ?? "").localeCompare(String(b["title"] ?? "")));
23457
+ else if (profile.sortBy === "status") {
23458
+ const order = {
23459
+ active: 0,
23460
+ draft: 1,
23461
+ frozen: 2,
23462
+ deprecated: 3
23463
+ };
23464
+ result.sort((a, b) => (order[a["status"]] ?? 9) - (order[b["status"]] ?? 9));
23465
+ } else if (profile.sortBy === "lastVerifiedDate") result.sort((a, b) => String(b["lastVerifiedDate"] ?? "").localeCompare(String(a["lastVerifiedDate"] ?? "")));
23466
+ return result;
23467
+ }
21874
23468
  //#endregion
21875
23469
  //#region src/commands/export.ts
21876
23470
  /**
@@ -22085,7 +23679,7 @@ function buildReconstructionPrompt(features, projectName, promptDir) {
22085
23679
  lines.push("");
22086
23680
  return lines.join("\n");
22087
23681
  }
22088
- const exportCommand = new Command("export").description("Export feature.json as JSON, Markdown, or generate a static HTML view").option("--out <path>", "Output file or directory path").option("--html [dir]", "Scan <dir> (default: cwd) and emit a single self-contained HTML wiki").option("--raw [dir]", "Raw field-by-field HTML dump with sidebar navigation").option("--print [dir]", "Print-ready HTML document (A4, all features, @media print CSS)").option("--postcard", "Beautiful single-feature shareable card (nearest feature.json)").option("--resume [dir]", "Portfolio page from all frozen features").option("--slide [dir]", "Full-screen HTML slideshow, one slide per feature").option("--graph [dir]", "Interactive force-directed feature lineage graph").option("--heatmap [dir]", "Completeness heatmap — fields × features grid").option("--quiz [dir]", "Flashcard-style quiz to test knowledge of your feature set").option("--story [dir]", "Long-form narrative document — product case study from feature data").option("--treemap [dir]", "Rectangular treemap — features sized by decisions × completeness, grouped by domain").option("--kanban [dir]", "Kanban board — Active / Frozen / Draft columns with sortable, filterable cards").option("--health [dir]", "Project health scorecard — completeness, coverage, tech debt, and health score").option("--embed [dir]", "Compact embeddable stats widget (iframe-ready)").option("--decisions [dir]", "Consolidated ADR — all decisions from all features, searchable by domain").option("--guide [dir]", "User guide — one page per feature that has a non-empty userGuide field").option("--hub [dir]", "Hub landing page linking to all generated views → lac-hub.html").option("--all [dir]", "Generate all HTML views + hub index.html → --out dir (default: ./lac-output)").option("--prefix <prefix>", "URL prefix for hub links (no leading slash), e.g. lac → hrefs become /lac/lac-guide.html").option("--diff <dir-b>", "Compare cwd workspace against <dir-b> and show added/removed/changed").option("--site <dir>", "Generate a multi-page static site → --out dir (default: ./lac-site)").option("--prompt [dir]", "AI reconstruction prompt for all features (stdout or --out file)").option("--markdown", "Single feature as Markdown (nearest feature.json)").option("--tags <tags>", "Comma-separated tags to filter by (OR logic) — applies to all multi-feature modes").option("--sort <mode>", "Sort order for multi-feature modes: key (default) | build-order (parents before children)").option("--view <name>", `Audience view — filters and shapes exported fields. One of: ${VIEW_NAMES.join(", ")}`).addHelpText("after", `
23682
+ const exportCommand = new Command("export").description("Export feature.json as JSON, Markdown, or generate a static HTML view").option("--out <path>", "Output file or directory path").option("--html [dir]", "Scan <dir> (default: cwd) and emit a single self-contained HTML wiki").option("--raw [dir]", "Raw field-by-field HTML dump with sidebar navigation").option("--print [dir]", "Print-ready HTML document (A4, all features, @media print CSS)").option("--postcard", "Beautiful single-feature shareable card (nearest feature.json)").option("--resume [dir]", "Portfolio page from all frozen features").option("--slide [dir]", "Full-screen HTML slideshow, one slide per feature").option("--graph [dir]", "Interactive force-directed feature lineage graph").option("--heatmap [dir]", "Completeness heatmap — fields × features grid").option("--quiz [dir]", "Flashcard-style quiz to test knowledge of your feature set").option("--story [dir]", "Long-form narrative document — product case study from feature data").option("--treemap [dir]", "Rectangular treemap — features sized by decisions × completeness, grouped by domain").option("--kanban [dir]", "Kanban board — Active / Frozen / Draft columns with sortable, filterable cards").option("--health [dir]", "Project health scorecard — completeness, coverage, tech debt, and health score").option("--embed [dir]", "Compact embeddable stats widget (iframe-ready)").option("--decisions [dir]", "Consolidated ADR — all decisions from all features, searchable by domain").option("--guide [dir]", "User guide — one page per feature that has a non-empty userGuide field").option("--hub [dir]", "Hub landing page linking to all generated views → lac-hub.html").option("--all [dir]", "Generate all HTML views + hub index.html → --out dir (default: ./lac-output)").option("--prefix <prefix>", "URL prefix for hub links (no leading slash), e.g. lac → hrefs become /lac/lac-guide.html").option("--diff <dir-b>", "Compare cwd workspace against <dir-b> and show added/removed/changed").option("--site <dir>", "Generate a multi-page static site → --out dir (default: ./lac-site)").option("--prompt [dir]", "AI reconstruction prompt for all features (stdout or --out file)").option("--markdown", "Single feature as Markdown (nearest feature.json)").option("--changelog [dir]", "Structured changelog grouped by month — from revisions[] across all features").option("--since <date>", "Filter --changelog and --release-notes to entries after this date (YYYY-MM-DD)").option("--release-notes [dir]", "User-facing release notes — features that went frozen since --since date or --release version").option("--release <version>", "Filter --release-notes to features matching this releaseVersion (e.g. 3.5.0)").option("--sprint [dir]", "Sprint planning view — draft+active features sorted by priority, summary density").option("--api-surface [dir]", "Aggregated publicInterface[] reference across all features → lac-api-surface.html").option("--dependency-map [dir]", "Runtime dependency graph from externalDependencies[] → lac-depmap.html").option("--tags <tags>", "Comma-separated tags to filter by (OR logic) — applies to all multi-feature modes").option("--sort <mode>", "Sort order for multi-feature modes: key (default) | build-order (parents before children)").option("--view <name>", `Audience view — built-in (${VIEW_NAMES.join(", ")}) or custom name from lac.config.json views`).option("--density <level>", "Content density: summary | standard | verbose (default: standard)").addHelpText("after", `
22089
23683
  Examples:
22090
23684
  lac export --html HTML wiki (cwd) → lac-wiki.html
22091
23685
  lac export --raw Raw field dump → lac-raw.html
@@ -22121,11 +23715,15 @@ Views (--view):
22121
23715
  product Business problem, success criteria, and strategic decisions (no code)
22122
23716
  dev Full implementation context — code, decisions, snippets, and lineage
22123
23717
  tech Complete technical record — all fields including history and revisions`).action(async (options) => {
22124
- let activeView = options.view ? VIEWS[options.view] : void 0;
23718
+ const config = loadConfig(process$1.cwd());
23719
+ let activeView = options.view ? resolveView(options.view, config.views) : void 0;
22125
23720
  if (options.view && !activeView) {
22126
- process$1.stderr.write(`Error: unknown view "${options.view}". Valid views: ${VIEW_NAMES.join(", ")}\n`);
23721
+ const customNames = Object.keys(config.views);
23722
+ const allNames = [...VIEW_NAMES, ...customNames];
23723
+ process$1.stderr.write(`Error: unknown view "${options.view}". Available: ${allNames.join(", ")}\n`);
22127
23724
  process$1.exit(1);
22128
23725
  }
23726
+ const activeDensity = options.density === "summary" || options.density === "verbose" ? options.density : activeView && "density" in activeView && activeView.density ? activeView.density : "standard";
22129
23727
  if (options.sort && options.sort !== "key" && options.sort !== "build-order") {
22130
23728
  process$1.stderr.write(`Error: unknown sort mode "${options.sort}". Valid modes: key, build-order\n`);
22131
23729
  process$1.exit(1);
@@ -22183,7 +23781,8 @@ Views (--view):
22183
23781
  process$1.exit(0);
22184
23782
  }
22185
23783
  const projectName = basename(htmlDir);
22186
- const html = generateHtmlWiki(activeView ? features.map((f) => applyViewForHtml(f.feature, activeView)) : features.map((f) => f.feature), projectName, activeView?.label, activeView?.name);
23784
+ const densityFeatures = withDensity(features);
23785
+ const html = generateHtmlWiki(activeView ? densityFeatures.map((f) => applyViewForHtml(f.feature, activeView)) : densityFeatures.map((f) => f.feature), projectName, activeView?.label, activeView?.name);
22187
23786
  const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-wiki.html");
22188
23787
  try {
22189
23788
  await writeFile(outFile, html, "utf-8");
@@ -22215,7 +23814,8 @@ Views (--view):
22215
23814
  process$1.exit(0);
22216
23815
  }
22217
23816
  const projectName = basename(rawDir);
22218
- const html = generateRawHtml(activeView ? features.map((f) => applyViewForHtml(f.feature, activeView)) : features.map((f) => f.feature), projectName, activeView?.label, activeView?.name);
23817
+ const densityRaw = withDensity(features);
23818
+ const html = generateRawHtml(activeView ? densityRaw.map((f) => applyViewForHtml(f.feature, activeView)) : densityRaw.map((f) => f.feature), projectName, activeView?.label, activeView?.name);
22219
23819
  const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-raw.html");
22220
23820
  try {
22221
23821
  await writeFile(outFile, html, "utf-8");
@@ -22276,7 +23876,23 @@ Views (--view):
22276
23876
  const tagsToMatch = options.tags.split(",").map((t) => t.trim()).filter(Boolean);
22277
23877
  features = features.filter(({ feature }) => tagsToMatch.some((tag) => feature.tags?.includes(tag)));
22278
23878
  }
22279
- return applySort(features);
23879
+ if (activeView && ("filterStatus" in activeView || "sortBy" in activeView)) {
23880
+ const transformed = applyViewTransforms(features.map((f) => f.feature), {
23881
+ filterStatus: activeView.filterStatus,
23882
+ sortBy: activeView.sortBy
23883
+ });
23884
+ const transformedKeys = new Set(transformed.map((f) => f["featureKey"]));
23885
+ features = features.filter((f) => transformedKeys.has(f.feature.featureKey));
23886
+ }
23887
+ features = applySort(features);
23888
+ return features;
23889
+ }
23890
+ function withDensity(featureList) {
23891
+ if (activeDensity === "standard") return featureList;
23892
+ return featureList.map((f) => ({
23893
+ ...f,
23894
+ feature: applyDensity(f.feature, activeDensity)
23895
+ }));
22280
23896
  }
22281
23897
  if (options.print !== void 0) {
22282
23898
  const dir = typeof options.print === "string" ? resolve(options.print) : resolve(process$1.cwd());
@@ -22285,7 +23901,8 @@ Views (--view):
22285
23901
  process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
22286
23902
  process$1.exit(0);
22287
23903
  }
22288
- const html = generatePrint(activeView ? features.map((f) => applyViewForHtml(f.feature, activeView)) : features.map((f) => f.feature), basename(dir), activeView?.label);
23904
+ const dPrint = withDensity(features);
23905
+ const html = generatePrint(activeView ? dPrint.map((f) => applyViewForHtml(f.feature, activeView)) : dPrint.map((f) => f.feature), basename(dir), activeView?.label);
22289
23906
  const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-print.html");
22290
23907
  try {
22291
23908
  await writeFile(outFile, html, "utf-8");
@@ -22303,7 +23920,7 @@ Views (--view):
22303
23920
  process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
22304
23921
  process$1.exit(0);
22305
23922
  }
22306
- const html = generateResume(features.map((f) => f.feature), basename(dir));
23923
+ const html = generateResume(withDensity(features).map((f) => f.feature), basename(dir));
22307
23924
  const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-resume.html");
22308
23925
  try {
22309
23926
  await writeFile(outFile, html, "utf-8");
@@ -22321,7 +23938,8 @@ Views (--view):
22321
23938
  process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
22322
23939
  process$1.exit(0);
22323
23940
  }
22324
- const html = generateSlides(activeView ? features.map((f) => applyViewForHtml(f.feature, activeView)) : features.map((f) => f.feature), basename(dir), activeView?.label);
23941
+ const dSlide = withDensity(features);
23942
+ const html = generateSlides(activeView ? dSlide.map((f) => applyViewForHtml(f.feature, activeView)) : dSlide.map((f) => f.feature), basename(dir), activeView?.label);
22325
23943
  const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-slides.html");
22326
23944
  try {
22327
23945
  await writeFile(outFile, html, "utf-8");
@@ -22357,7 +23975,7 @@ Views (--view):
22357
23975
  process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
22358
23976
  process$1.exit(0);
22359
23977
  }
22360
- const html = generateStory(features.map((f) => f.feature), basename(dir));
23978
+ const html = generateStory(withDensity(features).map((f) => f.feature), basename(dir));
22361
23979
  const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-story.html");
22362
23980
  try {
22363
23981
  await writeFile(outFile, html, "utf-8");
@@ -22393,7 +24011,7 @@ Views (--view):
22393
24011
  process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
22394
24012
  process$1.exit(0);
22395
24013
  }
22396
- const html = generateKanban(features.map((f) => f.feature), basename(dir));
24014
+ const html = generateKanban(withDensity(features).map((f) => f.feature), basename(dir));
22397
24015
  const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-kanban.html");
22398
24016
  try {
22399
24017
  await writeFile(outFile, html, "utf-8");
@@ -22447,7 +24065,7 @@ Views (--view):
22447
24065
  process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
22448
24066
  process$1.exit(0);
22449
24067
  }
22450
- const fs = features.map((f) => f.feature);
24068
+ const fs = withDensity(features).map((f) => f.feature);
22451
24069
  const totalDecisions = fs.reduce((n, f) => n + (f.decisions?.length ?? 0), 0);
22452
24070
  const html = generateDecisionLog(fs, basename(dir));
22453
24071
  const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-decisions.html");
@@ -22507,6 +24125,104 @@ Views (--view):
22507
24125
  }
22508
24126
  return;
22509
24127
  }
24128
+ if (options.changelog !== void 0) {
24129
+ const dir = typeof options.changelog === "string" ? resolve(options.changelog) : resolve(process$1.cwd());
24130
+ const features = await scanAndFilter(dir);
24131
+ if (features.length === 0) {
24132
+ process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
24133
+ process$1.exit(0);
24134
+ }
24135
+ const fs = features.map((f) => f.feature);
24136
+ const html = generateChangelog(fs, basename(dir), options.since);
24137
+ const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-changelog.html");
24138
+ try {
24139
+ await writeFile(outFile, html, "utf-8");
24140
+ const totalRevisions = fs.reduce((n, f) => n + (f["revisions"]?.length ?? 0), 0);
24141
+ process$1.stdout.write(`✓ Changelog (${totalRevisions} revisions across ${features.length} features) → ${options.out ?? "lac-changelog.html"}\n`);
24142
+ } catch (err) {
24143
+ process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
24144
+ process$1.exit(1);
24145
+ }
24146
+ return;
24147
+ }
24148
+ if (options.releaseNotes !== void 0) {
24149
+ const dir = typeof options.releaseNotes === "string" ? resolve(options.releaseNotes) : resolve(process$1.cwd());
24150
+ const features = await scanAndFilter(dir);
24151
+ if (features.length === 0) {
24152
+ process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
24153
+ process$1.exit(0);
24154
+ }
24155
+ const html = generateReleaseNotes(features.map((f) => f.feature), basename(dir), {
24156
+ since: options.since,
24157
+ release: options.release
24158
+ });
24159
+ const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-release-notes.html");
24160
+ try {
24161
+ await writeFile(outFile, html, "utf-8");
24162
+ process$1.stdout.write(`✓ Release notes → ${options.out ?? "lac-release-notes.html"}\n`);
24163
+ } catch (err) {
24164
+ process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
24165
+ process$1.exit(1);
24166
+ }
24167
+ return;
24168
+ }
24169
+ if (options.sprint !== void 0) {
24170
+ const dir = typeof options.sprint === "string" ? resolve(options.sprint) : resolve(process$1.cwd());
24171
+ const features = await scanAndFilter(dir);
24172
+ if (features.length === 0) {
24173
+ process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
24174
+ process$1.exit(0);
24175
+ }
24176
+ const sprintFeatures = features.filter((f) => f.feature.status === "draft" || f.feature.status === "active");
24177
+ const html = generateSprint(sprintFeatures.map((f) => f.feature), basename(dir));
24178
+ const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-sprint.html");
24179
+ try {
24180
+ await writeFile(outFile, html, "utf-8");
24181
+ process$1.stdout.write(`✓ Sprint (${sprintFeatures.length} active+draft features) → ${options.out ?? "lac-sprint.html"}\n`);
24182
+ } catch (err) {
24183
+ process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
24184
+ process$1.exit(1);
24185
+ }
24186
+ return;
24187
+ }
24188
+ if (options.apiSurface !== void 0) {
24189
+ const dir = typeof options.apiSurface === "string" ? resolve(options.apiSurface) : resolve(process$1.cwd());
24190
+ const features = await scanAndFilter(dir);
24191
+ if (features.length === 0) {
24192
+ process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
24193
+ process$1.exit(0);
24194
+ }
24195
+ const fs = features.map((f) => f.feature);
24196
+ const totalEntries = fs.reduce((n, f) => n + (f["publicInterface"]?.length ?? 0), 0);
24197
+ const html = generateApiSurface(fs, basename(dir));
24198
+ const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-api-surface.html");
24199
+ try {
24200
+ await writeFile(outFile, html, "utf-8");
24201
+ process$1.stdout.write(`✓ API surface (${totalEntries} entries across ${features.length} features) → ${options.out ?? "lac-api-surface.html"}\n`);
24202
+ } catch (err) {
24203
+ process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
24204
+ process$1.exit(1);
24205
+ }
24206
+ return;
24207
+ }
24208
+ if (options.dependencyMap !== void 0) {
24209
+ const dir = typeof options.dependencyMap === "string" ? resolve(options.dependencyMap) : resolve(process$1.cwd());
24210
+ const features = await scanAndFilter(dir);
24211
+ if (features.length === 0) {
24212
+ process$1.stdout.write(`No valid feature.json files found in "${dir}".\n`);
24213
+ process$1.exit(0);
24214
+ }
24215
+ const html = generateDependencyMap(features.map((f) => f.feature), basename(dir));
24216
+ const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-depmap.html");
24217
+ try {
24218
+ await writeFile(outFile, html, "utf-8");
24219
+ process$1.stdout.write(`✓ Dependency map (${features.length} features) → ${options.out ?? "lac-depmap.html"}\n`);
24220
+ } catch (err) {
24221
+ process$1.stderr.write(`Error writing "${outFile}": ${err instanceof Error ? err.message : String(err)}\n`);
24222
+ process$1.exit(1);
24223
+ }
24224
+ return;
24225
+ }
22510
24226
  if (options.all !== void 0) {
22511
24227
  const dir = typeof options.all === "string" ? resolve(options.all) : resolve(process$1.cwd());
22512
24228
  const outDir = resolve(options.out ?? "./lac-output");
@@ -22543,15 +24259,71 @@ Views (--view):
22543
24259
  await write("lac-graph.html", generateGraph(fs, projectName));
22544
24260
  await write("lac-print.html", generatePrint(fs, projectName));
22545
24261
  await write("lac-raw.html", generateRawHtml(fs, projectName));
22546
- await write("index.html", generateHub(projectName, {
24262
+ await write("lac-changelog.html", generateChangelog(fs, projectName));
24263
+ await write("lac-release-notes.html", generateReleaseNotes(fs, projectName, {}));
24264
+ await write("lac-sprint.html", generateSprint(fs.filter((f) => f.status === "draft" || f.status === "active"), projectName));
24265
+ await write("lac-api-surface.html", generateApiSurface(fs, projectName));
24266
+ await write("lac-depmap.html", generateDependencyMap(fs, projectName));
24267
+ const stats = {
22547
24268
  total: fs.length,
22548
24269
  frozen: fs.filter((f) => f.status === "frozen").length,
22549
24270
  active: fs.filter((f) => f.status === "active").length,
22550
24271
  draft: fs.filter((f) => f.status === "draft").length,
22551
24272
  deprecated: fs.filter((f) => f.status === "deprecated").length,
22552
24273
  domains: [...new Set(fs.map((f) => f.domain).filter((d) => Boolean(d)))]
22553
- }, ALL_HUB_ENTRIES, (/* @__PURE__ */ new Date()).toISOString(), options.prefix));
22554
- process$1.stdout.write(`Done ${features.length} features, 11 files written to ${outDir}\n`);
24274
+ };
24275
+ const customEntries = [];
24276
+ const VIEW_ICON_MAP = {
24277
+ musician: "🎵",
24278
+ sprint: "⚡",
24279
+ "dev-deep": "🔬",
24280
+ "code-focus": "📦",
24281
+ shipped: "🚀",
24282
+ onboarding: "🎓",
24283
+ architect: "🏛️",
24284
+ support: "🛟",
24285
+ user: "👤",
24286
+ dev: "💻",
24287
+ product: "📊",
24288
+ tech: "🔧"
24289
+ };
24290
+ function pickViewIcon(name, label) {
24291
+ if (VIEW_ICON_MAP[name]) return VIEW_ICON_MAP[name];
24292
+ const lc = label.toLowerCase();
24293
+ if (lc.includes("music")) return "🎵";
24294
+ if (lc.includes("sprint") || lc.includes("active")) return "⚡";
24295
+ if (lc.includes("dev") || lc.includes("engineer")) return "🔬";
24296
+ if (lc.includes("code") || lc.includes("snippet")) return "📦";
24297
+ if (lc.includes("ship") || lc.includes("release") || lc.includes("frozen")) return "🚀";
24298
+ if (lc.includes("onboard") || lc.includes("guide") || lc.includes("new")) return "🎓";
24299
+ if (lc.includes("architect") || lc.includes("decision")) return "🏛️";
24300
+ if (lc.includes("support") || lc.includes("qa")) return "🛟";
24301
+ return "📄";
24302
+ }
24303
+ for (const [viewName, viewDef] of Object.entries(config.views)) {
24304
+ const resolved = resolveView(viewName, config.views);
24305
+ if (!resolved) continue;
24306
+ const label = viewDef.label ?? viewName;
24307
+ const description = viewDef.description ?? `Custom view: ${viewName}`;
24308
+ const icon = pickViewIcon(viewName, label);
24309
+ const filename = `view-${viewName}.html`;
24310
+ let viewFeatures = features.map((f) => f.feature);
24311
+ viewFeatures = applyViewTransforms(viewFeatures, {
24312
+ filterStatus: resolved.filterStatus,
24313
+ sortBy: resolved.sortBy
24314
+ });
24315
+ await write(filename, generateHtmlWiki(viewFeatures.map((f) => applyViewForHtml(f, resolved)), projectName, label, viewName));
24316
+ customEntries.push({
24317
+ file: filename,
24318
+ label,
24319
+ description,
24320
+ icon,
24321
+ primary: false
24322
+ });
24323
+ }
24324
+ await write("index.html", generateHub(projectName, stats, [...ALL_HUB_ENTRIES, ...customEntries], (/* @__PURE__ */ new Date()).toISOString(), options.prefix));
24325
+ const totalFiles = 15 + customEntries.length + 1;
24326
+ process$1.stdout.write(`Done — ${features.length} features, ${totalFiles} files written to ${outDir}\n`);
22555
24327
  return;
22556
24328
  }
22557
24329
  if (options.graph !== void 0) {