@majeanson/lac 3.1.0 → 3.2.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
@@ -2,10 +2,11 @@ import { Command } from "commander";
2
2
  import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
3
  import process$1 from "node:process";
4
4
  import fs, { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
5
- import path, { dirname, join, resolve } from "node:path";
5
+ import path, { basename, dirname, join, resolve } from "node:path";
6
6
  import readline from "node:readline";
7
7
  import Anthropic from "@anthropic-ai/sdk";
8
8
  import { execSync, spawn, spawnSync } from "node:child_process";
9
+ import crypto from "node:crypto";
9
10
  import prompts from "prompts";
10
11
  import http from "node:http";
11
12
 
@@ -141,7 +142,7 @@ function mergeDefs$1(...defs) {
141
142
  }
142
143
  return Object.defineProperties({}, mergedDescriptors);
143
144
  }
144
- function esc$1(str) {
145
+ function esc$2(str) {
145
146
  return JSON.stringify(str);
146
147
  }
147
148
  function slugify$1(input) {
@@ -1543,7 +1544,7 @@ const $ZodObjectJIT$1 = /* @__PURE__ */ $constructor$1("$ZodObjectJIT", (inst, d
1543
1544
  ]);
1544
1545
  const normalized = _normalized.value;
1545
1546
  const parseStr = (key) => {
1546
- const k = esc$1(key);
1547
+ const k = esc$2(key);
1547
1548
  return `shape[${k}]._zod.run({ value: input[${k}], issues: [] }, ctx)`;
1548
1549
  };
1549
1550
  doc.write(`const input = payload.value;`);
@@ -1553,7 +1554,7 @@ const $ZodObjectJIT$1 = /* @__PURE__ */ $constructor$1("$ZodObjectJIT", (inst, d
1553
1554
  doc.write(`const newResult = {};`);
1554
1555
  for (const key of normalized.keys) {
1555
1556
  const id = ids[key];
1556
- const k = esc$1(key);
1557
+ const k = esc$2(key);
1557
1558
  const isOptionalOut = shape[key]?._zod?.optout === "optional";
1558
1559
  doc.write(`const ${id} = ${parseStr(key)};`);
1559
1560
  if (isOptionalOut) doc.write(`
@@ -3742,6 +3743,27 @@ const LineageSchema$1 = object$1({
3742
3743
  children: array$1(string$2()).optional(),
3743
3744
  spawnReason: string$2().nullable().optional()
3744
3745
  });
3746
+ const StatusTransitionSchema$1 = object$1({
3747
+ from: FeatureStatusSchema$1,
3748
+ to: FeatureStatusSchema$1,
3749
+ date: string$2().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
3750
+ reason: string$2().optional()
3751
+ });
3752
+ const RevisionSchema$1 = object$1({
3753
+ date: string$2().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
3754
+ author: string$2().min(1),
3755
+ fields_changed: array$1(string$2()).min(1),
3756
+ reason: string$2().min(1)
3757
+ });
3758
+ const PublicInterfaceEntrySchema$1 = object$1({
3759
+ name: string$2().min(1),
3760
+ type: string$2().min(1),
3761
+ description: string$2().optional()
3762
+ });
3763
+ const CodeSnippetSchema$1 = object$1({
3764
+ label: string$2().min(1),
3765
+ snippet: string$2().min(1)
3766
+ });
3745
3767
  const FeatureSchema$1 = object$1({
3746
3768
  featureKey: string$2().regex(FEATURE_KEY_PATTERN$1, "featureKey must match pattern <domain>-YYYY-NNN (e.g. feat-2026-001, proc-2026-001)"),
3747
3769
  title: string$2().min(1),
@@ -3757,7 +3779,20 @@ const FeatureSchema$1 = object$1({
3757
3779
  annotations: array$1(AnnotationSchema$1).optional(),
3758
3780
  lineage: LineageSchema$1.optional(),
3759
3781
  successCriteria: string$2().optional(),
3760
- domain: string$2().optional()
3782
+ domain: string$2().optional(),
3783
+ priority: number$2().int().min(1).max(5).optional(),
3784
+ statusHistory: array$1(StatusTransitionSchema$1).optional(),
3785
+ revisions: array$1(RevisionSchema$1).optional(),
3786
+ superseded_by: string$2().regex(FEATURE_KEY_PATTERN$1, "superseded_by must be a valid featureKey").optional(),
3787
+ superseded_from: array$1(string$2().regex(FEATURE_KEY_PATTERN$1, "each superseded_from entry must be a valid featureKey")).optional(),
3788
+ merged_into: string$2().regex(FEATURE_KEY_PATTERN$1, "merged_into must be a valid featureKey").optional(),
3789
+ merged_from: array$1(string$2().regex(FEATURE_KEY_PATTERN$1, "each merged_from entry must be a valid featureKey")).optional(),
3790
+ componentFile: string$2().optional(),
3791
+ npmPackages: array$1(string$2()).optional(),
3792
+ publicInterface: array$1(PublicInterfaceEntrySchema$1).optional(),
3793
+ externalDependencies: array$1(string$2()).optional(),
3794
+ lastVerifiedDate: string$2().regex(/^\d{4}-\d{2}-\d{2}$/, "lastVerifiedDate must be YYYY-MM-DD").optional(),
3795
+ codeSnippets: array$1(CodeSnippetSchema$1).optional()
3761
3796
  });
3762
3797
 
3763
3798
  //#endregion
@@ -3797,7 +3832,7 @@ function padCounter(n) {
3797
3832
  * Walks up the directory tree from `fromDir` to find the nearest `.lac/` directory.
3798
3833
  * Returns the path to the `.lac/` directory if found, otherwise null.
3799
3834
  */
3800
- function findLacDir$2(fromDir) {
3835
+ function findLacDir$3(fromDir) {
3801
3836
  let current = path.resolve(fromDir);
3802
3837
  while (true) {
3803
3838
  const candidate = path.join(current, LAC_DIR$2);
@@ -3830,7 +3865,7 @@ function findLacDir$2(fromDir) {
3830
3865
  * in `lac.config.json` to get keys like "proc-2026-001".
3831
3866
  */
3832
3867
  function generateFeatureKey(fromDir, prefix = "feat") {
3833
- const lacDir = findLacDir$2(fromDir);
3868
+ const lacDir = findLacDir$3(fromDir);
3834
3869
  if (!lacDir) throw new Error(`Could not find a .lac/ directory in "${fromDir}" or any of its parents. Run "lac workspace init" to initialise a life-as-code workspace.`);
3835
3870
  const counterPath = path.join(lacDir, COUNTER_FILE$1);
3836
3871
  const keysPath = path.join(lacDir, KEYS_FILE);
@@ -3870,8 +3905,9 @@ function generateFeatureKey(fromDir, prefix = "feat") {
3870
3905
  * Recursively finds all feature.json files under a directory.
3871
3906
  * Returns an array of { filePath, feature } for each valid feature.json found.
3872
3907
  * Files that fail validation are skipped with a warning printed to stderr.
3908
+ * Skips _archive/ directories unless includeArchived is true.
3873
3909
  */
3874
- async function scanFeatures(dir) {
3910
+ async function scanFeatures(dir, scanOptions = {}) {
3875
3911
  const results = [];
3876
3912
  async function walk(currentDir) {
3877
3913
  let entries;
@@ -3892,6 +3928,7 @@ async function scanFeatures(dir) {
3892
3928
  const fullPath = join(currentDir, entry.name);
3893
3929
  if (entry.isDirectory()) {
3894
3930
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
3931
+ if (entry.name === "_archive" && !scanOptions.includeArchived) continue;
3895
3932
  await walk(fullPath);
3896
3933
  } else if (entry.isFile() && entry.name === "feature.json") {
3897
3934
  let raw;
@@ -3927,7 +3964,7 @@ async function scanFeatures(dir) {
3927
3964
 
3928
3965
  //#endregion
3929
3966
  //#region src/commands/archive.ts
3930
- const archiveCommand = new Command("archive").description("Mark a feature as deprecated (archived)").argument("<key>", "featureKey to archive (e.g. feat-2026-001)").option("-d, --dir <path>", "Directory to scan (default: cwd)").action(async (key, options) => {
3967
+ const archiveCommand = new Command("archive").description("Mark a feature as deprecated (archived)").argument("<key>", "featureKey to archive (e.g. feat-2026-001)").option("-d, --dir <path>", "Directory to scan (default: cwd)").option("--superseded-by <key>", "featureKey that supersedes this one").option("--merged-into <key>", "featureKey this one was merged into").action(async (key, options) => {
3931
3968
  const scanDir = options.dir ?? process$1.cwd();
3932
3969
  const found = (await scanFeatures(scanDir)).find((f) => f.feature.featureKey === key);
3933
3970
  if (!found) {
@@ -3938,16 +3975,27 @@ const archiveCommand = new Command("archive").description("Mark a feature as dep
3938
3975
  process$1.stdout.write(`Already deprecated: ${key}\n`);
3939
3976
  process$1.exit(0);
3940
3977
  }
3978
+ if (options.supersededBy && !FEATURE_KEY_PATTERN$1.test(options.supersededBy)) {
3979
+ process$1.stderr.write(`Error: --superseded-by "${options.supersededBy}" is not a valid featureKey\n`);
3980
+ process$1.exit(1);
3981
+ }
3982
+ if (options.mergedInto && !FEATURE_KEY_PATTERN$1.test(options.mergedInto)) {
3983
+ process$1.stderr.write(`Error: --merged-into "${options.mergedInto}" is not a valid featureKey\n`);
3984
+ process$1.exit(1);
3985
+ }
3941
3986
  const raw = await readFile(found.filePath, "utf-8");
3942
3987
  const parsed = JSON.parse(raw);
3943
3988
  parsed["status"] = "deprecated";
3989
+ if (options.supersededBy) parsed["superseded_by"] = options.supersededBy;
3990
+ if (options.mergedInto) parsed["merged_into"] = options.mergedInto;
3944
3991
  const validation = validateFeature$1(parsed);
3945
3992
  if (!validation.success) {
3946
3993
  process$1.stderr.write(`Validation error: ${validation.errors.join(", ")}\n`);
3947
3994
  process$1.exit(1);
3948
3995
  }
3949
3996
  await writeFile(found.filePath, JSON.stringify(validation.data, null, 2) + "\n", "utf-8");
3950
- process$1.stdout.write(`✓ ${key} archived (status deprecated)\n`);
3997
+ const pointerNote = options.supersededBy ? ` (superseded by ${options.supersededBy})` : options.mergedInto ? ` (merged into ${options.mergedInto})` : "";
3998
+ process$1.stdout.write(`✓ ${key} archived (status → deprecated)${pointerNote}\n`);
3951
3999
  });
3952
4000
 
3953
4001
  //#endregion
@@ -4079,7 +4127,7 @@ function mergeDefs(...defs) {
4079
4127
  }
4080
4128
  return Object.defineProperties({}, mergedDescriptors);
4081
4129
  }
4082
- function esc(str) {
4130
+ function esc$1(str) {
4083
4131
  return JSON.stringify(str);
4084
4132
  }
4085
4133
  function slugify(input) {
@@ -5450,7 +5498,7 @@ const $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def)
5450
5498
  ]);
5451
5499
  const normalized = _normalized.value;
5452
5500
  const parseStr = (key) => {
5453
- const k = esc(key);
5501
+ const k = esc$1(key);
5454
5502
  return `shape[${k}]._zod.run({ value: input[${k}], issues: [] }, ctx)`;
5455
5503
  };
5456
5504
  doc.write(`const input = payload.value;`);
@@ -5460,7 +5508,7 @@ const $ZodObjectJIT = /* @__PURE__ */ $constructor("$ZodObjectJIT", (inst, def)
5460
5508
  doc.write(`const newResult = {};`);
5461
5509
  for (const key of normalized.keys) {
5462
5510
  const id = ids[key];
5463
- const k = esc(key);
5511
+ const k = esc$1(key);
5464
5512
  const isOptionalOut = shape[key]?._zod?.optout === "optional";
5465
5513
  doc.write(`const ${id} = ${parseStr(key)};`);
5466
5514
  if (isOptionalOut) doc.write(`
@@ -7593,6 +7641,27 @@ const LineageSchema = object({
7593
7641
  children: array(string()).optional(),
7594
7642
  spawnReason: string().nullable().optional()
7595
7643
  });
7644
+ const StatusTransitionSchema = object({
7645
+ from: FeatureStatusSchema,
7646
+ to: FeatureStatusSchema,
7647
+ date: string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
7648
+ reason: string().optional()
7649
+ });
7650
+ const RevisionSchema = object({
7651
+ date: string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
7652
+ author: string().min(1),
7653
+ fields_changed: array(string()).min(1),
7654
+ reason: string().min(1)
7655
+ });
7656
+ const PublicInterfaceEntrySchema = object({
7657
+ name: string().min(1),
7658
+ type: string().min(1),
7659
+ description: string().optional()
7660
+ });
7661
+ const CodeSnippetSchema = object({
7662
+ label: string().min(1),
7663
+ snippet: string().min(1)
7664
+ });
7596
7665
  const FeatureSchema = object({
7597
7666
  featureKey: string().regex(FEATURE_KEY_PATTERN, "featureKey must match pattern <domain>-YYYY-NNN (e.g. feat-2026-001, proc-2026-001)"),
7598
7667
  title: string().min(1),
@@ -7608,7 +7677,20 @@ const FeatureSchema = object({
7608
7677
  annotations: array(AnnotationSchema).optional(),
7609
7678
  lineage: LineageSchema.optional(),
7610
7679
  successCriteria: string().optional(),
7611
- domain: string().optional()
7680
+ domain: string().optional(),
7681
+ priority: number().int().min(1).max(5).optional(),
7682
+ statusHistory: array(StatusTransitionSchema).optional(),
7683
+ revisions: array(RevisionSchema).optional(),
7684
+ superseded_by: string().regex(FEATURE_KEY_PATTERN, "superseded_by must be a valid featureKey").optional(),
7685
+ superseded_from: array(string().regex(FEATURE_KEY_PATTERN, "each superseded_from entry must be a valid featureKey")).optional(),
7686
+ merged_into: string().regex(FEATURE_KEY_PATTERN, "merged_into must be a valid featureKey").optional(),
7687
+ merged_from: array(string().regex(FEATURE_KEY_PATTERN, "each merged_from entry must be a valid featureKey")).optional(),
7688
+ componentFile: string().optional(),
7689
+ npmPackages: array(string()).optional(),
7690
+ publicInterface: array(PublicInterfaceEntrySchema).optional(),
7691
+ externalDependencies: array(string()).optional(),
7692
+ lastVerifiedDate: string().regex(/^\d{4}-\d{2}-\d{2}$/, "lastVerifiedDate must be YYYY-MM-DD").optional(),
7693
+ codeSnippets: array(CodeSnippetSchema).optional()
7612
7694
  });
7613
7695
  function validateFeature(data) {
7614
7696
  const result = FeatureSchema.safeParse(data);
@@ -7657,7 +7739,7 @@ async function generateText(client, systemPrompt, userMessage, model = "claude-s
7657
7739
  if (!content || content.type !== "text") throw new Error("Unexpected response type from Claude API");
7658
7740
  return content.text;
7659
7741
  }
7660
- const SOURCE_EXTENSIONS = new Set([
7742
+ const SOURCE_EXTENSIONS$1 = new Set([
7661
7743
  ".ts",
7662
7744
  ".tsx",
7663
7745
  ".js",
@@ -7675,16 +7757,20 @@ const SOURCE_EXTENSIONS = new Set([
7675
7757
  ]);
7676
7758
  const MAX_FILE_CHARS = 8e3;
7677
7759
  const MAX_TOTAL_CHARS = 32e4;
7678
- function buildContext(featureDir, feature) {
7760
+ function buildContext(featureDir, feature, opts = {}) {
7761
+ const featurePath = path.join(featureDir, "feature.json");
7762
+ const { files: sourceFiles, truncatedPaths } = gatherSourceFiles(featureDir, opts.maxFileChars);
7679
7763
  return {
7680
7764
  feature,
7681
- featurePath: path.join(featureDir, "feature.json"),
7682
- sourceFiles: gatherSourceFiles(featureDir),
7683
- gitLog: getGitLog(featureDir)
7765
+ featurePath,
7766
+ sourceFiles,
7767
+ gitLog: getGitLog(featureDir),
7768
+ truncatedFiles: truncatedPaths
7684
7769
  };
7685
7770
  }
7686
- function gatherSourceFiles(dir) {
7771
+ function gatherSourceFiles(dir, maxFileChars = MAX_FILE_CHARS) {
7687
7772
  const files = [];
7773
+ const truncatedPaths = [];
7688
7774
  let totalChars = 0;
7689
7775
  const priorityNames = [
7690
7776
  "package.json",
@@ -7695,15 +7781,19 @@ function gatherSourceFiles(dir) {
7695
7781
  for (const name of priorityNames) {
7696
7782
  const p = path.join(dir, name);
7697
7783
  if (fs.existsSync(p)) try {
7698
- const content = truncate(fs.readFileSync(p, "utf-8"), 4e3);
7784
+ const raw = fs.readFileSync(p, "utf-8");
7785
+ const wasTruncated = raw.length > 4e3;
7786
+ const content = truncate(raw, 4e3);
7699
7787
  files.push({
7700
7788
  relativePath: name,
7701
- content
7789
+ content,
7790
+ truncated: wasTruncated || void 0
7702
7791
  });
7792
+ if (wasTruncated) truncatedPaths.push(name);
7703
7793
  totalChars += content.length;
7704
7794
  } catch {}
7705
7795
  }
7706
- const allSource = walkDir(dir).filter((f) => SOURCE_EXTENSIONS.has(path.extname(f)) && !f.includes("node_modules") && !f.includes(".turbo") && !f.includes("dist/"));
7796
+ const allSource = walkDir(dir).filter((f) => SOURCE_EXTENSIONS$1.has(path.extname(f)) && !f.includes("node_modules") && !f.includes(".turbo") && !f.includes("dist/"));
7707
7797
  allSource.sort((a, b) => {
7708
7798
  const aTest = /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(a);
7709
7799
  return aTest === /\.(test|spec)\.(ts|tsx|js|jsx)$/.test(b) ? 0 : aTest ? 1 : -1;
@@ -7712,16 +7802,23 @@ function gatherSourceFiles(dir) {
7712
7802
  if (totalChars >= MAX_TOTAL_CHARS) break;
7713
7803
  if (priorityNames.includes(path.basename(filePath))) continue;
7714
7804
  try {
7715
- const content = truncate(fs.readFileSync(filePath, "utf-8"), MAX_FILE_CHARS);
7805
+ const raw = fs.readFileSync(filePath, "utf-8");
7806
+ const wasTruncated = raw.length > maxFileChars;
7807
+ const content = truncate(raw, maxFileChars);
7716
7808
  const relativePath = path.relative(dir, filePath);
7717
7809
  files.push({
7718
7810
  relativePath,
7719
- content
7811
+ content,
7812
+ truncated: wasTruncated || void 0
7720
7813
  });
7814
+ if (wasTruncated) truncatedPaths.push(relativePath);
7721
7815
  totalChars += content.length;
7722
7816
  } catch {}
7723
7817
  }
7724
- return files;
7818
+ return {
7819
+ files,
7820
+ truncatedPaths
7821
+ };
7725
7822
  }
7726
7823
  function walkDir(dir) {
7727
7824
  const results = [];
@@ -7757,6 +7854,7 @@ function getGitLog(dir) {
7757
7854
  }
7758
7855
  function contextToString(ctx) {
7759
7856
  const parts = [];
7857
+ if (ctx.truncatedFiles.length > 0) parts.push(`⚠ WARNING: ${ctx.truncatedFiles.length} file(s) were truncated — extraction may be incomplete:\n` + ctx.truncatedFiles.map((f) => ` - ${f}`).join("\n"));
7760
7858
  parts.push("=== feature.json ===");
7761
7859
  parts.push(JSON.stringify(ctx.feature, null, 2));
7762
7860
  if (ctx.gitLog) {
@@ -7764,7 +7862,7 @@ function contextToString(ctx) {
7764
7862
  parts.push(ctx.gitLog);
7765
7863
  }
7766
7864
  for (const file of ctx.sourceFiles) {
7767
- parts.push(`\n=== ${file.relativePath} ===`);
7865
+ parts.push(`\n=== ${file.relativePath}${file.truncated ? " [truncated]" : ""} ===`);
7768
7866
  parts.push(file.content);
7769
7867
  }
7770
7868
  return parts.join("\n");
@@ -7849,13 +7947,64 @@ Return ONLY a valid JSON array — no other text:
7849
7947
  domain: {
7850
7948
  system: `You are a software engineering analyst. Identify the primary technical domain for this feature from its code and problem statement. Return a single lowercase word or short hyphenated phrase (e.g. "auth", "payments", "notifications", "data-pipeline"). Return only the domain value — nothing else.`,
7851
7949
  userSuffix: "Identify the domain for this feature."
7950
+ },
7951
+ componentFile: {
7952
+ system: `You are a software engineering analyst. Given a feature.json and its source code, identify the single primary file that implements this feature. Return a relative path from the project root (e.g. "src/components/FeatureCard.tsx", "packages/lac-mcp/src/index.ts"). Return only the path — nothing else.`,
7953
+ userSuffix: "Identify the primary source file for this feature."
7954
+ },
7955
+ npmPackages: {
7956
+ system: `You are a software engineering analyst. Given a feature.json and its source code, list the npm packages this feature directly imports or depends on at runtime. Exclude dev-only tools (vitest, eslint, etc.). Exclude Node built-ins.
7957
+
7958
+ Return ONLY a valid JSON array of package name strings — no other text:
7959
+ ["package-a", "package-b"]`,
7960
+ userSuffix: "List the npm packages this feature depends on."
7961
+ },
7962
+ publicInterface: {
7963
+ system: `You are a software engineering analyst. Given a feature.json and its source code, extract the public interface — exported props, function signatures, or API surface that consumers of this feature depend on.
7964
+
7965
+ Return ONLY a valid JSON array — no other text:
7966
+ [
7967
+ {
7968
+ "name": "string",
7969
+ "type": "string",
7970
+ "description": "string"
7971
+ }
7972
+ ]`,
7973
+ userSuffix: "Extract the public interface for this feature."
7974
+ },
7975
+ externalDependencies: {
7976
+ system: `You are a software engineering analyst. Given a feature.json and its source code, identify runtime dependencies on other features or internal modules that are NOT captured by the lineage (parent/children). These are cross-feature implementation dependencies — e.g. a feature that calls into another feature's API at runtime, or imports a shared utility that belongs to a distinct feature.
7977
+
7978
+ Return ONLY a valid JSON array of featureKey strings or relative file paths — no other text:
7979
+ ["feat-2026-003", "src/utils/shared.ts"]`,
7980
+ userSuffix: "List the external runtime dependencies for this feature."
7981
+ },
7982
+ lastVerifiedDate: {
7983
+ system: `You are a software engineering analyst. Return today's date in YYYY-MM-DD format as the lastVerifiedDate — marking that this feature.json was reviewed and confirmed accurate right now. Return only the date string — nothing else.`,
7984
+ userSuffix: `Return today's date as the lastVerifiedDate.`
7985
+ },
7986
+ codeSnippets: {
7987
+ system: `You are a software engineering analyst. Given a feature.json and its source code, extract 2-5 critical one-liners or short code blocks that are the most important to preserve verbatim — glob patterns, key API calls, non-obvious configuration, or architectural pivots. These are the snippets someone would need to reconstruct this feature accurately.
7988
+
7989
+ Return ONLY a valid JSON array — no other text:
7990
+ [
7991
+ {
7992
+ "label": "string",
7993
+ "snippet": "string"
7994
+ }
7995
+ ]`,
7996
+ userSuffix: "Extract the critical code snippets for this feature."
7852
7997
  }
7853
7998
  };
7854
7999
  const JSON_FIELDS = new Set([
7855
8000
  "decisions",
7856
8001
  "knownLimitations",
7857
8002
  "tags",
7858
- "annotations"
8003
+ "annotations",
8004
+ "npmPackages",
8005
+ "publicInterface",
8006
+ "externalDependencies",
8007
+ "codeSnippets"
7859
8008
  ]);
7860
8009
  const ALL_FILLABLE_FIELDS = [
7861
8010
  "analysis",
@@ -7864,7 +8013,13 @@ const ALL_FILLABLE_FIELDS = [
7864
8013
  "knownLimitations",
7865
8014
  "tags",
7866
8015
  "successCriteria",
7867
- "domain"
8016
+ "domain",
8017
+ "componentFile",
8018
+ "npmPackages",
8019
+ "publicInterface",
8020
+ "externalDependencies",
8021
+ "lastVerifiedDate",
8022
+ "codeSnippets"
7868
8023
  ];
7869
8024
  function getMissingFields(feature) {
7870
8025
  return ALL_FILLABLE_FIELDS.filter((field) => {
@@ -7893,8 +8048,94 @@ const GEN_PROMPTS = {
7893
8048
  userSuffix: "Generate user-facing documentation for this feature."
7894
8049
  }
7895
8050
  };
8051
+ const PROMPT_LOG_FILENAME = "prompt.log.jsonl";
8052
+ /** Append one or more entries to the feature's prompt.log.jsonl. Creates the file if absent. */
8053
+ function appendPromptLog(featureDir, entries) {
8054
+ if (entries.length === 0) return;
8055
+ const logPath = path.join(featureDir, PROMPT_LOG_FILENAME);
8056
+ const lines = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
8057
+ fs.appendFileSync(logPath, lines, "utf-8");
8058
+ }
8059
+ /** 8-char sha256 prefix of a string — stable identifier for a prompt version. */
8060
+ function hashPrompt(prompt) {
8061
+ return crypto.createHash("sha256").update(prompt).digest("hex").slice(0, 8);
8062
+ }
8063
+ const EXTRACT_SYSTEM = `You are a software engineering analyst. Given source code from a repository directory, generate a complete feature descriptor for this module or package.
8064
+
8065
+ Return ONLY valid JSON with EXACTLY these fields — no markdown fences, no explanation:
8066
+ {
8067
+ "title": "Short descriptive name (5-10 words)",
8068
+ "problem": "What problem does this module solve? 1-2 sentences.",
8069
+ "domain": "Single lowercase word or hyphenated phrase (e.g. auth, data-pipeline, payments)",
8070
+ "tags": ["3-6 lowercase tags reflecting actual domain language"],
8071
+ "analysis": "Architectural overview: what the code does, key patterns used, why they were chosen. Name actual functions/modules/techniques visible in the code. 150-300 words.",
8072
+ "decisions": [
8073
+ {
8074
+ "decision": "what was decided (concrete, e.g. 'Use JWT for session tokens')",
8075
+ "rationale": "why, based on code evidence",
8076
+ "alternativesConsidered": ["alternative 1", "alternative 2"],
8077
+ "date": null
8078
+ }
8079
+ ],
8080
+ "implementation": "Main components and their roles, how data flows through the module, non-obvious patterns or constraints. 100-200 words.",
8081
+ "knownLimitations": ["2-4 limitations, TODOs, or tech-debt items visible in the code"],
8082
+ "successCriteria": "How do we know this module works correctly? 1-3 testable sentences."
8083
+ }
8084
+
8085
+ Include 2-4 decisions. Be specific — generic observations are not useful.`;
8086
+ /**
8087
+ * Given a directory with source code (no feature.json required),
8088
+ * calls Claude in a single API request and returns all feature fields.
8089
+ *
8090
+ * This is designed for bulk extraction (lac extract-all) where
8091
+ * one API call per module is more efficient than field-by-field filling.
8092
+ */
8093
+ async function extractFeature(options) {
8094
+ const { dir, model = "claude-sonnet-4-6" } = options;
8095
+ const client = createClient();
8096
+ const ctx = buildContext(dir, {
8097
+ featureKey: "feat-2026-000",
8098
+ title: "(pending extraction)",
8099
+ status: "draft",
8100
+ problem: "(pending)"
8101
+ });
8102
+ if (ctx.sourceFiles.length === 0) throw new Error(`No source files found in "${dir}".`);
8103
+ const parts = [];
8104
+ if (ctx.truncatedFiles.length > 0) {
8105
+ parts.push(`⚠ WARNING: ${ctx.truncatedFiles.length} file(s) were truncated — extraction may be incomplete:\n` + ctx.truncatedFiles.map((f) => ` - ${f}`).join("\n"));
8106
+ parts.push("");
8107
+ }
8108
+ if (ctx.gitLog) {
8109
+ parts.push("=== git log (last 20 commits) ===");
8110
+ parts.push(ctx.gitLog);
8111
+ }
8112
+ for (const file of ctx.sourceFiles) {
8113
+ parts.push(`\n=== ${file.relativePath}${file.truncated ? " [truncated]" : ""} ===`);
8114
+ parts.push(file.content);
8115
+ }
8116
+ const raw = await generateText(client, EXTRACT_SYSTEM, `Directory: ${dir}\n\nSource files:\n\n${parts.join("\n")}\n\nGenerate the feature descriptor JSON for this module.`, model);
8117
+ const jsonStr = (raw.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1] ?? raw).trim();
8118
+ let parsed;
8119
+ try {
8120
+ parsed = JSON.parse(jsonStr);
8121
+ } catch {
8122
+ throw new Error(`Claude returned invalid JSON for "${dir}".\nRaw response:\n${raw.slice(0, 500)}`);
8123
+ }
8124
+ const result = parsed;
8125
+ return {
8126
+ title: String(result["title"] ?? "Untitled Module"),
8127
+ problem: String(result["problem"] ?? "Problem statement not extracted."),
8128
+ domain: String(result["domain"] ?? "general"),
8129
+ tags: Array.isArray(result["tags"]) ? result["tags"] : [],
8130
+ analysis: String(result["analysis"] ?? ""),
8131
+ decisions: Array.isArray(result["decisions"]) ? result["decisions"] : [],
8132
+ implementation: String(result["implementation"] ?? ""),
8133
+ knownLimitations: Array.isArray(result["knownLimitations"]) ? result["knownLimitations"] : [],
8134
+ successCriteria: String(result["successCriteria"] ?? "")
8135
+ };
8136
+ }
7896
8137
  async function fillFeature(options) {
7897
- const { featureDir, dryRun = false, skipConfirm = false, model = "claude-sonnet-4-6" } = options;
8138
+ const { featureDir, dryRun = false, skipConfirm = false, model = "claude-sonnet-4-6", defaultAuthor = "" } = options;
7898
8139
  const featurePath = path.join(featureDir, "feature.json");
7899
8140
  let raw;
7900
8141
  try {
@@ -7928,12 +8169,17 @@ async function fillFeature(options) {
7928
8169
  process$1.stdout.write(`Generating with ${model}...\n`);
7929
8170
  const patch = {};
7930
8171
  const diffs = [];
8172
+ const rawResponses = /* @__PURE__ */ new Map();
7931
8173
  for (const field of fieldsToFill) {
7932
8174
  const prompt = FILL_PROMPTS[field];
7933
8175
  if (!prompt) continue;
7934
8176
  process$1.stdout.write(` → ${field}...`);
7935
8177
  try {
7936
8178
  const rawValue = await generateText(client, prompt.system, `${contextStr}\n\n${prompt.userSuffix}`, model);
8179
+ rawResponses.set(field, {
8180
+ raw: rawValue,
8181
+ systemPrompt: prompt.system
8182
+ });
7937
8183
  let value = rawValue.trim();
7938
8184
  if (JSON_FIELDS.has(field)) try {
7939
8185
  const jsonStr = rawValue.match(/```(?:json)?\s*([\s\S]*?)```/)?.[1] ?? rawValue;
@@ -7970,7 +8216,7 @@ async function fillFeature(options) {
7970
8216
  };
7971
8217
  }
7972
8218
  if (!skipConfirm) {
7973
- const answer = await askUser("Apply? [Y]es / [n]o / [f]ield-by-field: ");
8219
+ const answer = await askUser$1("Apply? [Y]es / [n]o / [f]ield-by-field: ");
7974
8220
  if (answer.toLowerCase() === "n") {
7975
8221
  process$1.stdout.write(" Cancelled.\n");
7976
8222
  return {
@@ -7981,16 +8227,60 @@ async function fillFeature(options) {
7981
8227
  }
7982
8228
  if (answer.toLowerCase() === "f") {
7983
8229
  const approved = {};
7984
- for (const [field, value] of Object.entries(patch)) if ((await askUser(` Apply "${field}"? [Y/n]: `)).toLowerCase() !== "n") approved[field] = value;
8230
+ for (const [field, value] of Object.entries(patch)) if ((await askUser$1(` Apply "${field}"? [Y/n]: `)).toLowerCase() !== "n") approved[field] = value;
7985
8231
  for (const key of Object.keys(patch)) if (!(key in approved)) delete patch[key];
7986
8232
  Object.assign(patch, approved);
7987
8233
  }
7988
8234
  }
8235
+ const INTENT_CRITICAL$1 = new Set([
8236
+ "problem",
8237
+ "analysis",
8238
+ "implementation",
8239
+ "decisions",
8240
+ "successCriteria"
8241
+ ]);
8242
+ const changedCritical = Object.keys(patch).filter((k) => {
8243
+ if (!INTENT_CRITICAL$1.has(k)) return false;
8244
+ const existing = feature[k];
8245
+ if (existing === void 0 || existing === null) return false;
8246
+ if (typeof existing === "string") return existing.trim().length > 0;
8247
+ if (Array.isArray(existing)) return existing.length > 0;
8248
+ return false;
8249
+ });
8250
+ const base = parsed;
8251
+ let updatedRevisions = base.revisions ?? [];
8252
+ if (!skipConfirm && changedCritical.length > 0) {
8253
+ process$1.stdout.write(`\n Intent-critical fields changed: ${changedCritical.join(", ")}\n`);
8254
+ const author = await askUser$1(defaultAuthor ? ` Revision author [${defaultAuthor}]: ` : " Revision author: ") || defaultAuthor;
8255
+ const reason = await askUser$1(" Reason for change: ");
8256
+ if (author && reason) {
8257
+ const today$1 = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
8258
+ updatedRevisions = [...updatedRevisions, {
8259
+ date: today$1,
8260
+ author,
8261
+ fields_changed: changedCritical,
8262
+ reason
8263
+ }];
8264
+ }
8265
+ }
7989
8266
  const updated = {
7990
- ...parsed,
7991
- ...patch
8267
+ ...base,
8268
+ ...patch,
8269
+ ...updatedRevisions.length > 0 ? { revisions: updatedRevisions } : {}
7992
8270
  };
7993
8271
  fs.writeFileSync(featurePath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
8272
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8273
+ appendPromptLog(featureDir, Object.keys(patch).map((field) => {
8274
+ const captured = rawResponses.get(field);
8275
+ return {
8276
+ date: now,
8277
+ field,
8278
+ source: "lac fill",
8279
+ model,
8280
+ prompt_hash: captured ? hashPrompt(captured.systemPrompt) : void 0,
8281
+ response_preview: captured ? captured.raw.slice(0, 120) : void 0
8282
+ };
8283
+ }));
7994
8284
  const count = Object.keys(patch).length;
7995
8285
  process$1.stdout.write(`\n ✓ Updated ${feature.featureKey} — ${count} field${count === 1 ? "" : "s"} written.\n\n`);
7996
8286
  return {
@@ -8036,7 +8326,7 @@ function typeToExt(type) {
8036
8326
  docs: ".md"
8037
8327
  }[type] ?? ".txt";
8038
8328
  }
8039
- function askUser(question) {
8329
+ function askUser$1(question) {
8040
8330
  return new Promise((resolve$1) => {
8041
8331
  const rl = readline.createInterface({
8042
8332
  input: process$1.stdin,
@@ -8049,6 +8339,463 @@ function askUser(question) {
8049
8339
  });
8050
8340
  }
8051
8341
 
8342
+ //#endregion
8343
+ //#region src/lib/partitioner.ts
8344
+ /**
8345
+ * Files whose presence signals a module/package boundary.
8346
+ * Used by the 'module' strategy.
8347
+ */
8348
+ const MODULE_SIGNAL_FILES = new Set([
8349
+ "package.json",
8350
+ "index.ts",
8351
+ "index.js",
8352
+ "index.tsx",
8353
+ "index.mts",
8354
+ "mod.ts",
8355
+ "go.mod",
8356
+ "main.go",
8357
+ "Cargo.toml",
8358
+ "lib.rs",
8359
+ "main.rs",
8360
+ "pyproject.toml",
8361
+ "setup.py",
8362
+ "setup.cfg",
8363
+ "__init__.py",
8364
+ "main.py",
8365
+ "pom.xml",
8366
+ "build.gradle",
8367
+ "build.gradle.kts",
8368
+ "*.csproj",
8369
+ "Gemfile",
8370
+ "composer.json"
8371
+ ]);
8372
+ /** Source file extensions used to count files and determine if a dir has any code */
8373
+ const SOURCE_EXTENSIONS = new Set([
8374
+ ".ts",
8375
+ ".tsx",
8376
+ ".mts",
8377
+ ".cts",
8378
+ ".js",
8379
+ ".jsx",
8380
+ ".mjs",
8381
+ ".cjs",
8382
+ ".py",
8383
+ ".go",
8384
+ ".rs",
8385
+ ".java",
8386
+ ".kt",
8387
+ ".scala",
8388
+ ".cs",
8389
+ ".rb",
8390
+ ".php",
8391
+ ".vue",
8392
+ ".svelte",
8393
+ ".sql",
8394
+ ".c",
8395
+ ".cpp",
8396
+ ".h",
8397
+ ".hpp",
8398
+ ".swift"
8399
+ ]);
8400
+ /**
8401
+ * Directory names that are always skipped.
8402
+ * These are either build artifacts, dependency trees, or known non-feature dirs.
8403
+ */
8404
+ const BUILTIN_SKIP_DIRS = new Set([
8405
+ "node_modules",
8406
+ ".git",
8407
+ ".svn",
8408
+ ".hg",
8409
+ "dist",
8410
+ "build",
8411
+ "out",
8412
+ "output",
8413
+ "__pycache__",
8414
+ ".turbo",
8415
+ "coverage",
8416
+ ".nyc_output",
8417
+ "vendor",
8418
+ "target",
8419
+ ".next",
8420
+ ".nuxt",
8421
+ ".cache",
8422
+ ".venv",
8423
+ "venv",
8424
+ "env",
8425
+ ".env",
8426
+ "tmp",
8427
+ "temp",
8428
+ "_archive",
8429
+ "migrations",
8430
+ "fixtures",
8431
+ "mocks",
8432
+ "__mocks__",
8433
+ "stubs",
8434
+ ".idea",
8435
+ ".vscode"
8436
+ ]);
8437
+ /**
8438
+ * Walk a directory tree and return candidate directories for feature extraction.
8439
+ *
8440
+ * - Already-documented directories (those containing feature.json) are skipped
8441
+ * and reported separately via the `alreadyDocumented` return value.
8442
+ * - Parent/child relationships are computed: each candidate's `parentDir` points
8443
+ * to the nearest ancestor that is also a candidate.
8444
+ */
8445
+ function findCandidates(rootDir, options) {
8446
+ const root = path.resolve(rootDir);
8447
+ const skipSet = new Set([...BUILTIN_SKIP_DIRS, ...options.ignore]);
8448
+ const candidates = [];
8449
+ const alreadyDocumented = [];
8450
+ const skipped = [];
8451
+ function walk(dir, depth) {
8452
+ if (depth > options.maxDepth) return;
8453
+ let entries;
8454
+ try {
8455
+ entries = fs.readdirSync(dir, { withFileTypes: true });
8456
+ } catch {
8457
+ skipped.push(dir);
8458
+ return;
8459
+ }
8460
+ const names = new Set(entries.filter((e) => e.isFile()).map((e) => e.name));
8461
+ if (names.has("feature.json")) alreadyDocumented.push(dir);
8462
+ else if (depth > 0) {
8463
+ const signals = getSignals(names, options.strategy);
8464
+ const sourceFileCount = countSourceFiles(dir);
8465
+ if (options.strategy === "module" ? signals.length > 0 : sourceFileCount > 0) candidates.push({
8466
+ dir,
8467
+ relativePath: path.relative(root, dir),
8468
+ signals,
8469
+ sourceFileCount,
8470
+ parentDir: null
8471
+ });
8472
+ }
8473
+ for (const entry of entries) {
8474
+ if (!entry.isDirectory()) continue;
8475
+ if (entry.name.startsWith(".") || skipSet.has(entry.name)) continue;
8476
+ walk(path.join(dir, entry.name), depth + 1);
8477
+ }
8478
+ }
8479
+ walk(root, 0);
8480
+ candidates.sort((a, b) => a.dir.split(path.sep).length - b.dir.split(path.sep).length);
8481
+ const candidateDirs = new Set(candidates.map((c) => c.dir));
8482
+ for (const candidate of candidates) candidate.parentDir = findNearestCandidateAncestor(candidate.dir, root, candidateDirs);
8483
+ return {
8484
+ candidates,
8485
+ alreadyDocumented,
8486
+ skipped
8487
+ };
8488
+ }
8489
+ function getSignals(names, strategy) {
8490
+ if (strategy === "directory") return [...names].filter((n) => SOURCE_EXTENSIONS.has(path.extname(n)));
8491
+ const signals = [];
8492
+ for (const name of names) {
8493
+ if (MODULE_SIGNAL_FILES.has(name)) signals.push(name);
8494
+ if (name.endsWith(".csproj") || name.endsWith(".fsproj") || name.endsWith(".vbproj")) signals.push(name);
8495
+ }
8496
+ return signals;
8497
+ }
8498
+ function countSourceFiles(dir) {
8499
+ let count = 0;
8500
+ function walk(d) {
8501
+ let entries;
8502
+ try {
8503
+ entries = fs.readdirSync(d, { withFileTypes: true });
8504
+ } catch {
8505
+ return;
8506
+ }
8507
+ for (const e of entries) if (e.isFile() && SOURCE_EXTENSIONS.has(path.extname(e.name))) count++;
8508
+ else if (e.isDirectory() && !e.name.startsWith(".") && !BUILTIN_SKIP_DIRS.has(e.name)) walk(path.join(d, e.name));
8509
+ }
8510
+ walk(dir);
8511
+ return count;
8512
+ }
8513
+ function findNearestCandidateAncestor(dir, root, candidateDirs) {
8514
+ let current = path.dirname(dir);
8515
+ while (current !== root && current !== path.dirname(current)) {
8516
+ if (candidateDirs.has(current)) return current;
8517
+ current = path.dirname(current);
8518
+ }
8519
+ return null;
8520
+ }
8521
+ /**
8522
+ * Given a raw directory name, produce a human-readable title.
8523
+ * e.g. "lac-mcp" → "Lac Mcp", "authService" → "Auth Service"
8524
+ */
8525
+ function titleFromDirName(dirName) {
8526
+ return dirName.replace(/[-_]/g, " ").replace(/([a-z])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").replace(/\b\w/g, (c) => c.toUpperCase()).trim() || "Unnamed Module";
8527
+ }
8528
+
8529
+ //#endregion
8530
+ //#region src/commands/extract-all.ts
8531
+ function ensureLacDir(targetRoot) {
8532
+ const lacDir = path.join(targetRoot, ".lac");
8533
+ if (!fs.existsSync(lacDir)) {
8534
+ fs.mkdirSync(lacDir, { recursive: true });
8535
+ const year = (/* @__PURE__ */ new Date()).getFullYear();
8536
+ fs.writeFileSync(path.join(lacDir, "counter"), `${year}\n0\n`, "utf-8");
8537
+ process$1.stdout.write(` ✓ Initialised .lac/ workspace at "${lacDir}"\n`);
8538
+ }
8539
+ return lacDir;
8540
+ }
8541
+ function findLacDir$2(fromDir) {
8542
+ let current = path.resolve(fromDir);
8543
+ while (true) {
8544
+ const candidate = path.join(current, ".lac");
8545
+ if (fs.existsSync(candidate)) return candidate;
8546
+ const parent = path.dirname(current);
8547
+ if (parent === current) return null;
8548
+ current = parent;
8549
+ }
8550
+ }
8551
+ function writeDraftFeatureJson(featureDir, featureKey, title, problem) {
8552
+ fs.mkdirSync(featureDir, { recursive: true });
8553
+ const feature = {
8554
+ featureKey,
8555
+ title,
8556
+ status: "draft",
8557
+ problem,
8558
+ schemaVersion: 1
8559
+ };
8560
+ fs.writeFileSync(path.join(featureDir, "feature.json"), JSON.stringify(feature, null, 2) + "\n", "utf-8");
8561
+ }
8562
+ function mergeExtractedFields(featureDir, fields) {
8563
+ const featurePath = path.join(featureDir, "feature.json");
8564
+ const updated = {
8565
+ ...JSON.parse(fs.readFileSync(featurePath, "utf-8")),
8566
+ title: fields.title,
8567
+ problem: fields.problem,
8568
+ domain: fields.domain,
8569
+ tags: fields.tags,
8570
+ analysis: fields.analysis,
8571
+ decisions: fields.decisions,
8572
+ implementation: fields.implementation,
8573
+ knownLimitations: fields.knownLimitations,
8574
+ successCriteria: fields.successCriteria
8575
+ };
8576
+ fs.writeFileSync(featurePath, JSON.stringify(updated, null, 2) + "\n", "utf-8");
8577
+ }
8578
+ function scanNonFreshStatuses(dirs) {
8579
+ const nonFresh = [];
8580
+ for (const dir of dirs) {
8581
+ const featurePath = path.join(dir, "feature.json");
8582
+ if (!fs.existsSync(featurePath)) continue;
8583
+ try {
8584
+ const parsed = JSON.parse(fs.readFileSync(featurePath, "utf-8"));
8585
+ const status = String(parsed["status"] ?? "draft");
8586
+ if (status !== "draft" && status !== "active") nonFresh.push({
8587
+ dir,
8588
+ status
8589
+ });
8590
+ } catch {}
8591
+ }
8592
+ return nonFresh;
8593
+ }
8594
+ function applyStatusReset(snapshots, toStatus) {
8595
+ let count = 0;
8596
+ for (const { dir } of snapshots) {
8597
+ const featurePath = path.join(dir, "feature.json");
8598
+ try {
8599
+ const parsed = JSON.parse(fs.readFileSync(featurePath, "utf-8"));
8600
+ parsed["status"] = toStatus;
8601
+ delete parsed["statusHistory"];
8602
+ fs.writeFileSync(featurePath, JSON.stringify(parsed, null, 2) + "\n", "utf-8");
8603
+ count++;
8604
+ } catch {}
8605
+ }
8606
+ return count;
8607
+ }
8608
+ function wireLineage(featureDir, featureKey, parentDir, dirToKey) {
8609
+ if (!parentDir) return;
8610
+ const parentKey = dirToKey.get(parentDir);
8611
+ if (!parentKey) return;
8612
+ const childPath = path.join(featureDir, "feature.json");
8613
+ const child = JSON.parse(fs.readFileSync(childPath, "utf-8"));
8614
+ child["lineage"] = {
8615
+ ...child["lineage"] ?? {},
8616
+ parent: parentKey
8617
+ };
8618
+ fs.writeFileSync(childPath, JSON.stringify(child, null, 2) + "\n", "utf-8");
8619
+ const parentPath = path.join(parentDir, "feature.json");
8620
+ if (!fs.existsSync(parentPath)) return;
8621
+ const parent = JSON.parse(fs.readFileSync(parentPath, "utf-8"));
8622
+ const parentLineage = parent["lineage"] ?? {};
8623
+ const children = parentLineage["children"] ?? [];
8624
+ if (!children.includes(featureKey)) {
8625
+ parentLineage["children"] = [...children, featureKey];
8626
+ parent["lineage"] = parentLineage;
8627
+ fs.writeFileSync(parentPath, JSON.stringify(parent, null, 2) + "\n", "utf-8");
8628
+ }
8629
+ }
8630
+ const extractAllCommand = new Command("extract-all").description("Walk a repository and generate feature.json files for every module/directory").argument("[path]", "Root directory to scan (default: current directory)").option("--strategy <strategy>", "Partitioning strategy: \"module\" (package boundaries) or \"directory\" (all dirs with source)", "module").option("--depth <n>", "Max directory depth to descend (default: 4 for module, 2 for directory)").option("--fill", "Fill all fields with Claude API after creating draft feature.jsons").option("--model <model>", "Claude model to use with --fill (default: claude-sonnet-4-6)", "claude-sonnet-4-6").option("--dry-run", "Show what would be created without writing any files").option("--prefix <prefix>", "featureKey prefix (default: feat)", "feat").option("--ignore <patterns>", "Comma-separated directory names to skip (added to built-in skip list)", "").option("--init-workspace", "Create .lac/ directory in the target root if one is not found").option("--skip-confirm", "Skip interactive prompts (useful for CI or scripted use)").option("--concurrency <n>", "Number of parallel Claude API calls during --fill (default: 1)", "1").option("--reset-status", "Reset frozen/deprecated feature.json statuses to active (fresh-start for a new project). Prompts if not set.").action(async (targetArg, opts) => {
8631
+ const targetRoot = path.resolve(targetArg ?? process$1.cwd());
8632
+ const strategy = opts.strategy ?? "module";
8633
+ const defaultDepth = strategy === "directory" ? 2 : 4;
8634
+ const maxDepth = opts.depth !== void 0 ? Number(opts.depth) : defaultDepth;
8635
+ const fill = opts.fill ?? false;
8636
+ const model = opts.model ?? "claude-sonnet-4-6";
8637
+ const dryRun = opts.dryRun ?? false;
8638
+ const prefix = opts.prefix ?? "feat";
8639
+ const ignore = (opts.ignore ?? "").split(",").map((s) => s.trim()).filter(Boolean);
8640
+ const initWorkspace = opts.initWorkspace ?? false;
8641
+ const skipConfirm = opts.skipConfirm ?? fill;
8642
+ const concurrency = Math.max(1, Number(opts.concurrency ?? 1));
8643
+ const resetStatus = opts.resetStatus ?? false;
8644
+ if (!fs.existsSync(targetRoot)) {
8645
+ process$1.stderr.write(`Error: path "${targetRoot}" does not exist.\n`);
8646
+ process$1.exit(1);
8647
+ }
8648
+ if (initWorkspace) ensureLacDir(targetRoot);
8649
+ if (!findLacDir$2(targetRoot)) {
8650
+ process$1.stderr.write(`Error: no .lac/ workspace found in "${targetRoot}" or any of its parents.\nRun "lac workspace init" inside the target directory first, or pass --init-workspace.\n`);
8651
+ process$1.exit(1);
8652
+ }
8653
+ process$1.stdout.write(`\nScanning "${targetRoot}"...\n`);
8654
+ process$1.stdout.write(` Strategy : ${strategy} Depth: ${maxDepth}\n`);
8655
+ const { candidates, alreadyDocumented, skipped } = findCandidates(targetRoot, {
8656
+ strategy,
8657
+ maxDepth,
8658
+ ignore
8659
+ });
8660
+ const resumable = [];
8661
+ if (fill) for (const docDir of alreadyDocumented) {
8662
+ const absDir = path.resolve(targetRoot, docDir);
8663
+ try {
8664
+ const raw = fs.readFileSync(path.join(absDir, "feature.json"), "utf-8");
8665
+ const parsed = JSON.parse(raw);
8666
+ const isEmpty = (v) => v === void 0 || v === null || typeof v === "string" && v.startsWith("TODO:") || Array.isArray(v) && v.length === 0;
8667
+ if (isEmpty(parsed["analysis"]) && isEmpty(parsed["decisions"]) && isEmpty(parsed["implementation"])) resumable.push(absDir);
8668
+ } catch {}
8669
+ }
8670
+ if (alreadyDocumented.length > 0) {
8671
+ const skippedCount = alreadyDocumented.length - resumable.length;
8672
+ if (skippedCount > 0) process$1.stdout.write(` Skipping ${skippedCount} already-documented director${skippedCount === 1 ? "y" : "ies"}.\n`);
8673
+ if (resumable.length > 0) process$1.stdout.write(` Resuming ${resumable.length} incomplete fill${resumable.length === 1 ? "" : "s"} (draft with empty fields).\n`);
8674
+ }
8675
+ if (skipped.length > 0) process$1.stdout.write(` Skipping ${skipped.length} unreadable director${skipped.length === 1 ? "y" : "ies"}.\n`);
8676
+ const nonFreshSnapshots = scanNonFreshStatuses(alreadyDocumented.map((d) => path.resolve(targetRoot, d)));
8677
+ if (nonFreshSnapshots.length > 0) {
8678
+ const byStatus = nonFreshSnapshots.reduce((acc, { status }) => {
8679
+ acc[status] = (acc[status] ?? 0) + 1;
8680
+ return acc;
8681
+ }, {});
8682
+ const summary = Object.entries(byStatus).map(([s, n]) => `${n} ${s}`).join(", ");
8683
+ if (resetStatus) {
8684
+ const n = applyStatusReset(nonFreshSnapshots, "active");
8685
+ process$1.stdout.write(` ✓ Reset ${n} feature${n === 1 ? "" : "s"} (${summary}) → active.\n`);
8686
+ } else if (!skipConfirm) {
8687
+ process$1.stdout.write(`\n Found ${nonFreshSnapshots.length} existing feature${nonFreshSnapshots.length === 1 ? "" : "s"} with non-fresh statuses (${summary}).\n Reset to active for a fresh start? [y/N/skip]: `);
8688
+ const answer = (await readLine$1()).toLowerCase();
8689
+ if (answer === "y") {
8690
+ const n = applyStatusReset(nonFreshSnapshots, "active");
8691
+ process$1.stdout.write(` ✓ Reset ${n} feature${n === 1 ? "" : "s"} → active.\n`);
8692
+ } else if (answer !== "skip") process$1.stdout.write(" Statuses left unchanged.\n");
8693
+ }
8694
+ }
8695
+ if (candidates.length === 0 && resumable.length === 0) {
8696
+ process$1.stdout.write("\nNo undocumented modules found.\n");
8697
+ if (strategy === "module") process$1.stdout.write("Tip: try --strategy directory to include all directories with source files.\n");
8698
+ return;
8699
+ }
8700
+ process$1.stdout.write(`\nFound ${candidates.length} module${candidates.length === 1 ? "" : "s"} to document:\n\n`);
8701
+ for (const c of candidates) {
8702
+ const rel = c.relativePath || ".";
8703
+ const indent = c.parentDir ? " └─ " : " ";
8704
+ const hint = c.signals.length > 0 ? ` [${c.signals.slice(0, 2).join(", ")}]` : "";
8705
+ process$1.stdout.write(`${indent}${rel.padEnd(40)} ${String(c.sourceFileCount).padStart(3)} src files${hint}\n`);
8706
+ }
8707
+ if (dryRun) {
8708
+ process$1.stdout.write("\n[dry-run] No files written.\n\n");
8709
+ return;
8710
+ }
8711
+ if (!skipConfirm) {
8712
+ const suffix = fill ? " and fill all fields with Claude API" : "";
8713
+ process$1.stdout.write(`\nCreate ${candidates.length} draft feature.json file${candidates.length === 1 ? "" : "s"}${suffix}? [Y/n]: `);
8714
+ if ((await readLine$1()).toLowerCase() === "n") {
8715
+ process$1.stdout.write("Aborted.\n");
8716
+ return;
8717
+ }
8718
+ }
8719
+ process$1.stdout.write("\n");
8720
+ const dirToKey = /* @__PURE__ */ new Map();
8721
+ const created = [];
8722
+ const failed = [];
8723
+ for (const candidate of candidates) {
8724
+ const rel = candidate.relativePath || ".";
8725
+ try {
8726
+ const featureKey = generateFeatureKey(candidate.dir, prefix);
8727
+ dirToKey.set(candidate.dir, featureKey);
8728
+ const heuristicTitle = titleFromDirName(path.basename(candidate.dir));
8729
+ writeDraftFeatureJson(candidate.dir, featureKey, heuristicTitle, `TODO: describe what problem the ${heuristicTitle} module solves.`);
8730
+ created.push(featureKey);
8731
+ process$1.stdout.write(` ${rel} → ${featureKey} (draft)\n`);
8732
+ } catch (err) {
8733
+ const msg = err instanceof Error ? err.message : String(err);
8734
+ process$1.stdout.write(` ${rel} ✗ ${msg}\n`);
8735
+ failed.push(rel);
8736
+ }
8737
+ }
8738
+ if (fill && (created.length > 0 || resumable.length > 0)) {
8739
+ const resumableCandidates = resumable.map((dir) => ({
8740
+ dir,
8741
+ relativePath: path.relative(targetRoot, dir),
8742
+ signals: [],
8743
+ sourceFileCount: 0,
8744
+ parentDir: null
8745
+ }));
8746
+ const toFill = [...candidates.filter((c) => dirToKey.has(c.dir)), ...resumableCandidates];
8747
+ process$1.stdout.write(`\nFilling ${toFill.length} feature${toFill.length === 1 ? "" : "s"} with AI`);
8748
+ if (concurrency > 1) process$1.stdout.write(` (concurrency: ${concurrency})`);
8749
+ process$1.stdout.write("...\n");
8750
+ let doneCount = 0;
8751
+ let fillFailed = 0;
8752
+ async function fillOne(candidate) {
8753
+ const rel = candidate.relativePath || ".";
8754
+ try {
8755
+ const fields = await extractFeature({
8756
+ dir: candidate.dir,
8757
+ model
8758
+ });
8759
+ mergeExtractedFields(candidate.dir, fields);
8760
+ doneCount++;
8761
+ process$1.stdout.write(` [${doneCount}/${toFill.length}] ${rel} ✓\n`);
8762
+ } catch (err) {
8763
+ fillFailed++;
8764
+ const msg = err instanceof Error ? err.message.slice(0, 80) : String(err);
8765
+ process$1.stdout.write(` [${doneCount + fillFailed}/${toFill.length}] ${rel} ⚠ ${msg}\n`);
8766
+ }
8767
+ }
8768
+ for (let i = 0; i < toFill.length; i += concurrency) {
8769
+ const batch = toFill.slice(i, i + concurrency);
8770
+ await Promise.all(batch.map(fillOne));
8771
+ }
8772
+ if (fillFailed > 0) process$1.stdout.write(` ${fillFailed} fill${fillFailed === 1 ? "" : "s"} failed — drafts remain, run "lac fill <dir>" to retry.\n`);
8773
+ }
8774
+ let lineageCount = 0;
8775
+ for (const candidate of candidates) if (candidate.parentDir && dirToKey.has(candidate.dir)) try {
8776
+ wireLineage(candidate.dir, dirToKey.get(candidate.dir), candidate.parentDir, dirToKey);
8777
+ lineageCount++;
8778
+ } catch {}
8779
+ process$1.stdout.write("\n");
8780
+ process$1.stdout.write(`✓ Created ${created.length} feature.json file${created.length === 1 ? "" : "s"}`);
8781
+ if (lineageCount > 0) process$1.stdout.write(`, wired ${lineageCount} parent/child link${lineageCount === 1 ? "" : "s"}`);
8782
+ if (failed.length > 0) process$1.stdout.write(`, ${failed.length} failed`);
8783
+ process$1.stdout.write("\n");
8784
+ process$1.stdout.write(`\nNext steps:\n` + (fill ? "" : ` lac fill --all Fill all features with AI\n`) + " lac lint Check for incomplete features\n lac export --prompt . Bundle all features into a reconstruction prompt\n lac export --site . Generate a static HTML site\n\n");
8785
+ });
8786
+ function readLine$1() {
8787
+ return new Promise((resolve$1) => {
8788
+ const rl = readline.createInterface({
8789
+ input: process$1.stdin,
8790
+ output: process$1.stdout
8791
+ });
8792
+ rl.once("line", (answer) => {
8793
+ rl.close();
8794
+ resolve$1(answer.trim());
8795
+ });
8796
+ });
8797
+ }
8798
+
8052
8799
  //#endregion
8053
8800
  //#region src/lib/walker.ts
8054
8801
  /**
@@ -8094,14 +8841,101 @@ function findLacConfig(startDir) {
8094
8841
  }
8095
8842
  }
8096
8843
 
8844
+ //#endregion
8845
+ //#region src/lib/config.ts
8846
+ const DEFAULTS = {
8847
+ version: 1,
8848
+ requiredFields: ["problem"],
8849
+ ciThreshold: 0,
8850
+ lintStatuses: ["active", "draft"],
8851
+ domain: "feat",
8852
+ defaultAuthor: ""
8853
+ };
8854
+ function loadConfig(fromDir) {
8855
+ const configPath = findLacConfig(fromDir ?? process$1.cwd());
8856
+ if (!configPath) return { ...DEFAULTS };
8857
+ try {
8858
+ const raw = readFileSync(configPath, "utf-8");
8859
+ const parsed = JSON.parse(raw);
8860
+ return {
8861
+ version: parsed.version ?? DEFAULTS.version,
8862
+ requiredFields: parsed.requiredFields ?? DEFAULTS.requiredFields,
8863
+ ciThreshold: parsed.ciThreshold ?? DEFAULTS.ciThreshold,
8864
+ lintStatuses: parsed.lintStatuses ?? DEFAULTS.lintStatuses,
8865
+ domain: parsed.domain ?? DEFAULTS.domain,
8866
+ defaultAuthor: parsed.defaultAuthor ?? DEFAULTS.defaultAuthor
8867
+ };
8868
+ } catch {
8869
+ process$1.stderr.write(`Warning: could not parse lac.config.json at "${configPath}" — using defaults\n`);
8870
+ return { ...DEFAULTS };
8871
+ }
8872
+ }
8873
+ /** The 6 optional fields used to compute completeness score (0–100) */
8874
+ const OPTIONAL_FIELDS = [
8875
+ "analysis",
8876
+ "decisions",
8877
+ "implementation",
8878
+ "knownLimitations",
8879
+ "tags",
8880
+ "annotations"
8881
+ ];
8882
+ function computeCompleteness(feature) {
8883
+ const filled = OPTIONAL_FIELDS.filter((field) => {
8884
+ const val = feature[field];
8885
+ if (val === void 0 || val === null || val === "") return false;
8886
+ if (Array.isArray(val)) return val.length > 0;
8887
+ return typeof val === "string" && val.trim().length > 0;
8888
+ }).length;
8889
+ return Math.round(filled / OPTIONAL_FIELDS.length * 100);
8890
+ }
8891
+
8097
8892
  //#endregion
8098
8893
  //#region src/commands/fill.ts
8099
- const fillCommand = new Command("fill").description("Fill missing feature.json fields using AI analysis of your code").argument("[dir]", "Feature folder to fill (default: nearest feature.json from cwd)").option("--field <fields>", "Comma-separated fields to fill (default: all missing)").option("--dry-run", "Preview proposed changes without writing").option("--all", "Fill all features in the workspace below the completeness threshold").option("--threshold <n>", "Skip features above this completeness % (used with --all)", parseInt).option("--model <model>", "Claude model to use (default: claude-sonnet-4-6)").action(async (dir, options) => {
8894
+ const fillCommand = new Command("fill").description("Fill missing feature.json fields using AI analysis of your code").argument("[dir]", "Feature folder to fill (default: nearest feature.json from cwd)").option("--field <fields>", "Comma-separated fields to fill (default: all missing)").option("--dry-run", "Preview proposed changes without writing").option("--all", "Fill all features found under [dir] (or cwd) below the completeness threshold").option("--threshold <n>", "Completeness % ceiling for --all (default: 80 — skip features already above this)", parseInt).option("--model <model>", "Claude model to use (default: claude-sonnet-4-6)").action(async (dir, options) => {
8100
8895
  const fields = options.field ? options.field.split(",").map((f) => f.trim()).filter(Boolean) : void 0;
8101
8896
  if (options.all) {
8102
- process$1.stderr.write("--all flag coming soon. Run \"lac fill <dir>\" for a specific feature.\n");
8103
- process$1.exit(1);
8104
- }
8897
+ const threshold = options.threshold ?? 80;
8898
+ const scanDir = dir ? resolve(dir) : process$1.cwd();
8899
+ let allFeatures;
8900
+ try {
8901
+ allFeatures = await scanFeatures(scanDir);
8902
+ } catch (err) {
8903
+ process$1.stderr.write(`Error scanning "${scanDir}": ${err instanceof Error ? err.message : String(err)}\n`);
8904
+ process$1.exit(1);
8905
+ }
8906
+ const toFill = allFeatures.filter(({ feature }) => {
8907
+ if (feature.status === "deprecated") return false;
8908
+ return computeCompleteness(feature) < threshold;
8909
+ });
8910
+ if (toFill.length === 0) {
8911
+ process$1.stdout.write(`All features are above ${threshold}% completeness. Nothing to fill.\n`);
8912
+ return;
8913
+ }
8914
+ process$1.stdout.write(`\nFilling ${toFill.length} feature${toFill.length === 1 ? "" : "s"} below ${threshold}% completeness...\n\n`);
8915
+ let filled = 0;
8916
+ let failed = 0;
8917
+ for (const { filePath } of toFill) {
8918
+ const featureDir$1 = dirname(filePath);
8919
+ const cfg = loadConfig(featureDir$1);
8920
+ try {
8921
+ if ((await fillFeature({
8922
+ featureDir: featureDir$1,
8923
+ fields,
8924
+ dryRun: options.dryRun ?? false,
8925
+ skipConfirm: true,
8926
+ model: options.model,
8927
+ defaultAuthor: cfg.defaultAuthor || void 0
8928
+ })).applied) filled++;
8929
+ } catch (err) {
8930
+ process$1.stderr.write(` Error filling "${featureDir$1}": ${err instanceof Error ? err.message : String(err)}\n`);
8931
+ failed++;
8932
+ }
8933
+ }
8934
+ process$1.stdout.write(`\n✓ Filled ${filled} feature${filled === 1 ? "" : "s"}`);
8935
+ if (failed > 0) process$1.stdout.write(`, ${failed} failed`);
8936
+ process$1.stdout.write("\n");
8937
+ return;
8938
+ }
8105
8939
  let featureDir;
8106
8940
  if (dir) featureDir = resolve(dir);
8107
8941
  else {
@@ -8112,12 +8946,14 @@ const fillCommand = new Command("fill").description("Fill missing feature.json f
8112
8946
  }
8113
8947
  featureDir = dirname(found);
8114
8948
  }
8949
+ const config$2 = loadConfig(featureDir);
8115
8950
  try {
8116
8951
  await fillFeature({
8117
8952
  featureDir,
8118
8953
  fields,
8119
8954
  dryRun: options.dryRun ?? false,
8120
- model: options.model
8955
+ model: options.model,
8956
+ defaultAuthor: config$2.defaultAuthor || void 0
8121
8957
  });
8122
8958
  } catch (err) {
8123
8959
  process$1.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
@@ -8164,50 +9000,259 @@ const genCommand = new Command("gen").description("Generate code artifacts from
8164
9000
  });
8165
9001
 
8166
9002
  //#endregion
8167
- //#region src/lib/config.ts
8168
- const DEFAULTS = {
8169
- version: 1,
8170
- requiredFields: ["problem"],
8171
- ciThreshold: 0,
8172
- lintStatuses: ["active", "draft"],
8173
- domain: "feat"
8174
- };
8175
- function loadConfig(fromDir) {
8176
- const configPath = findLacConfig(fromDir ?? process$1.cwd());
8177
- if (!configPath) return { ...DEFAULTS };
9003
+ //#region src/commands/log.ts
9004
+ const logCommand = new Command("log").description("Show the intent history of a feature: revisions, status transitions, and annotations").argument("[dir]", "Feature folder (default: nearest feature.json from cwd)").option("--json", "Output as JSON").action(async (dir, options) => {
9005
+ let featureDir;
9006
+ if (dir) featureDir = resolve(dir);
9007
+ else {
9008
+ const found = findNearestFeatureJson$1(process$1.cwd());
9009
+ if (!found) {
9010
+ process$1.stderr.write("No feature.json found from current directory.\n");
9011
+ process$1.exit(1);
9012
+ }
9013
+ featureDir = dirname(found);
9014
+ }
9015
+ const featurePath = `${featureDir}/feature.json`;
9016
+ let raw;
8178
9017
  try {
8179
- const raw = readFileSync(configPath, "utf-8");
8180
- const parsed = JSON.parse(raw);
8181
- return {
8182
- version: parsed.version ?? DEFAULTS.version,
8183
- requiredFields: parsed.requiredFields ?? DEFAULTS.requiredFields,
8184
- ciThreshold: parsed.ciThreshold ?? DEFAULTS.ciThreshold,
8185
- lintStatuses: parsed.lintStatuses ?? DEFAULTS.lintStatuses,
8186
- domain: parsed.domain ?? DEFAULTS.domain
8187
- };
9018
+ raw = await readFile(featurePath, "utf-8");
8188
9019
  } catch {
8189
- process$1.stderr.write(`Warning: could not parse lac.config.json at "${configPath}" — using defaults\n`);
8190
- return { ...DEFAULTS };
9020
+ process$1.stderr.write(`No feature.json found at "${featurePath}"\n`);
9021
+ process$1.exit(1);
8191
9022
  }
8192
- }
8193
- /** The 6 optional fields used to compute completeness score (0–100) */
8194
- const OPTIONAL_FIELDS = [
9023
+ const result = validateFeature$1(JSON.parse(raw));
9024
+ if (!result.success) {
9025
+ process$1.stderr.write(`Invalid feature.json: ${result.errors.join(", ")}\n`);
9026
+ process$1.exit(1);
9027
+ }
9028
+ const feature = result.data;
9029
+ const raw2 = feature;
9030
+ const timeline = [];
9031
+ for (const rev of raw2.revisions ?? []) timeline.push({
9032
+ date: rev.date,
9033
+ type: "revision",
9034
+ label: `revised by ${rev.author}`,
9035
+ detail: `${rev.reason} [${rev.fields_changed.join(", ")}]`,
9036
+ author: rev.author
9037
+ });
9038
+ for (const st of raw2.statusHistory ?? []) timeline.push({
9039
+ date: st.date,
9040
+ type: "status",
9041
+ label: `status: ${st.from} → ${st.to}`,
9042
+ detail: st.reason ?? ""
9043
+ });
9044
+ for (const ann of feature.annotations ?? []) timeline.push({
9045
+ date: ann.date,
9046
+ type: "annotation",
9047
+ label: `[${ann.type}] by ${ann.author}`,
9048
+ detail: ann.body,
9049
+ author: ann.author
9050
+ });
9051
+ timeline.sort((a, b) => a.date.localeCompare(b.date));
9052
+ if (options.json) {
9053
+ process$1.stdout.write(JSON.stringify({
9054
+ featureKey: feature.featureKey,
9055
+ title: feature.title,
9056
+ timeline
9057
+ }, null, 2) + "\n");
9058
+ return;
9059
+ }
9060
+ if (timeline.length === 0) {
9061
+ process$1.stdout.write(`${feature.featureKey} ${feature.title}\n\nNo history recorded yet.\n`);
9062
+ return;
9063
+ }
9064
+ process$1.stdout.write(`${feature.featureKey} ${feature.title} [${feature.status}]\n`);
9065
+ process$1.stdout.write("─".repeat(60) + "\n\n");
9066
+ for (const entry of timeline) {
9067
+ const icon = entry.type === "revision" ? "✎" : entry.type === "status" ? "⟳" : "◆";
9068
+ process$1.stdout.write(`${icon} ${entry.date} ${entry.label}\n`);
9069
+ if (entry.detail) process$1.stdout.write(` ${entry.detail}\n`);
9070
+ process$1.stdout.write("\n");
9071
+ }
9072
+ });
9073
+
9074
+ //#endregion
9075
+ //#region src/commands/merge.ts
9076
+ const mergeCommand = new Command("merge").description("Merge two or more features into a target feature, setting pointers on all sides").argument("<source-keys>", "Comma-separated featureKeys to merge (will be deprecated)").requiredOption("--into <target-key>", "featureKey of the target feature to merge into").option("-d, --dir <path>", "Directory to scan (default: cwd)").option("--dry-run", "Preview changes without writing").action(async (sourceKeysArg, options) => {
9077
+ const scanDir = resolve(options.dir ?? process$1.cwd());
9078
+ const sourceKeys = sourceKeysArg.split(",").map((k) => k.trim()).filter(Boolean);
9079
+ const targetKey = options.into;
9080
+ if (sourceKeys.length === 0) {
9081
+ process$1.stderr.write("Error: at least one source key is required.\n");
9082
+ process$1.exit(1);
9083
+ }
9084
+ const features = await scanFeatures(scanDir);
9085
+ const byKey = new Map(features.map((f) => [f.feature.featureKey, f]));
9086
+ for (const key of sourceKeys) if (!byKey.has(key)) {
9087
+ process$1.stderr.write(`Error: feature "${key}" not found in "${scanDir}"\n`);
9088
+ process$1.exit(1);
9089
+ }
9090
+ const targetEntry = byKey.get(targetKey);
9091
+ if (!targetEntry) {
9092
+ process$1.stderr.write(`Error: target feature "${targetKey}" not found in "${scanDir}"\n`);
9093
+ process$1.exit(1);
9094
+ }
9095
+ process$1.stdout.write(`Merge: [${sourceKeys.join(", ")}] → ${targetKey}\n`);
9096
+ for (const key of sourceKeys) process$1.stdout.write(` ${key}: status → deprecated, merged_into = ${targetKey}\n`);
9097
+ process$1.stdout.write(` ${targetKey}: merged_from += [${sourceKeys.join(", ")}]\n`);
9098
+ if (options.dryRun) {
9099
+ process$1.stdout.write("[dry-run] No changes written.\n");
9100
+ return;
9101
+ }
9102
+ for (const key of sourceKeys) {
9103
+ const entry = byKey.get(key);
9104
+ const raw = JSON.parse(await readFile(entry.filePath, "utf-8"));
9105
+ raw.status = "deprecated";
9106
+ raw.merged_into = targetKey;
9107
+ const validation = validateFeature$1(raw);
9108
+ if (!validation.success) {
9109
+ process$1.stderr.write(`Validation error on "${key}": ${validation.errors.join(", ")}\n`);
9110
+ process$1.exit(1);
9111
+ }
9112
+ await writeFile(entry.filePath, JSON.stringify(validation.data, null, 2) + "\n", "utf-8");
9113
+ }
9114
+ const targetRaw = JSON.parse(await readFile(targetEntry.filePath, "utf-8"));
9115
+ const existingMergedFrom = targetRaw.merged_from ?? [];
9116
+ const toAdd = sourceKeys.filter((k) => !existingMergedFrom.includes(k));
9117
+ targetRaw.merged_from = [...existingMergedFrom, ...toAdd];
9118
+ const targetValidation = validateFeature$1(targetRaw);
9119
+ if (!targetValidation.success) {
9120
+ process$1.stderr.write(`Validation error on "${targetKey}": ${targetValidation.errors.join(", ")}\n`);
9121
+ process$1.exit(1);
9122
+ }
9123
+ await writeFile(targetEntry.filePath, JSON.stringify(targetValidation.data, null, 2) + "\n", "utf-8");
9124
+ process$1.stdout.write(`✓ ${sourceKeys.join(", ")} merged into ${targetKey}\n`);
9125
+ });
9126
+
9127
+ //#endregion
9128
+ //#region src/commands/revisions.ts
9129
+ const INTENT_CRITICAL = [
9130
+ "problem",
8195
9131
  "analysis",
8196
- "decisions",
8197
9132
  "implementation",
8198
- "knownLimitations",
8199
- "tags",
8200
- "annotations"
9133
+ "decisions",
9134
+ "successCriteria"
8201
9135
  ];
8202
- function computeCompleteness(feature) {
8203
- const filled = OPTIONAL_FIELDS.filter((field) => {
8204
- const val = feature[field];
8205
- if (val === void 0 || val === null || val === "") return false;
8206
- if (Array.isArray(val)) return val.length > 0;
8207
- return typeof val === "string" && val.trim().length > 0;
8208
- }).length;
8209
- return Math.round(filled / OPTIONAL_FIELDS.length * 100);
9136
+ function askUser(question) {
9137
+ return new Promise((resolve$1) => {
9138
+ const rl = readline.createInterface({
9139
+ input: process$1.stdin,
9140
+ output: process$1.stdout
9141
+ });
9142
+ rl.question(question, (answer) => {
9143
+ rl.close();
9144
+ resolve$1(answer.trim());
9145
+ });
9146
+ });
8210
9147
  }
9148
+ const baselineCommand = new Command("baseline").description("Add a first revision entry to all features that have intent-critical fields but no revisions").argument("[dir]", "Directory to scan (default: cwd)").option("--author <author>", "Author name for the baseline revision").option("--reason <reason>", "Reason text for the baseline revision (default: \"initial baseline\")").option("--dry-run", "Preview which features would be updated without writing").action(async (dir, options) => {
9149
+ const scanDir = resolve(dir ?? process$1.cwd());
9150
+ const config$2 = loadConfig(scanDir);
9151
+ const needsBaseline = (await scanFeatures(scanDir)).filter(({ feature }) => {
9152
+ const raw = feature;
9153
+ if (Array.isArray(raw.revisions) && raw.revisions.length > 0) return false;
9154
+ return INTENT_CRITICAL.some((field) => {
9155
+ const val = raw[field];
9156
+ if (val === void 0 || val === null) return false;
9157
+ if (typeof val === "string") return val.trim().length > 0;
9158
+ if (Array.isArray(val)) return val.length > 0;
9159
+ return false;
9160
+ });
9161
+ });
9162
+ if (needsBaseline.length === 0) {
9163
+ process$1.stdout.write("All features already have revision entries. Nothing to baseline.\n");
9164
+ return;
9165
+ }
9166
+ process$1.stdout.write(`Found ${needsBaseline.length} feature(s) without revisions:\n`);
9167
+ for (const { feature } of needsBaseline) process$1.stdout.write(` ${feature.featureKey} ${feature.title}\n`);
9168
+ process$1.stdout.write("\n");
9169
+ if (options.dryRun) {
9170
+ process$1.stdout.write("[dry-run] No changes written.\n");
9171
+ return;
9172
+ }
9173
+ const defaultAuthor = config$2.defaultAuthor;
9174
+ let author = options.author ?? defaultAuthor ?? "";
9175
+ if (!author) author = await askUser("Revision author (for all features): ");
9176
+ if (!author) {
9177
+ process$1.stderr.write("Error: author is required.\n");
9178
+ process$1.exit(1);
9179
+ }
9180
+ const reason = options.reason ?? "initial baseline — revisions tracking added retroactively";
9181
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
9182
+ let updated = 0;
9183
+ for (const { feature, filePath } of needsBaseline) {
9184
+ const raw = feature;
9185
+ const filledCritical = INTENT_CRITICAL.filter((field) => {
9186
+ const val = raw[field];
9187
+ if (val === void 0 || val === null) return false;
9188
+ if (typeof val === "string") return val.trim().length > 0;
9189
+ if (Array.isArray(val)) return val.length > 0;
9190
+ return false;
9191
+ });
9192
+ const revision = {
9193
+ date: today,
9194
+ author,
9195
+ fields_changed: filledCritical,
9196
+ reason
9197
+ };
9198
+ const content = JSON.parse(await readFile(filePath, "utf-8"));
9199
+ content.revisions = [revision];
9200
+ const validation = validateFeature$1(content);
9201
+ if (!validation.success) {
9202
+ process$1.stderr.write(` ✗ ${feature.featureKey} validation failed — skipping\n`);
9203
+ continue;
9204
+ }
9205
+ await writeFile(filePath, JSON.stringify(validation.data, null, 2) + "\n", "utf-8");
9206
+ process$1.stdout.write(` ✓ ${feature.featureKey} baselined (${filledCritical.join(", ")})\n`);
9207
+ updated++;
9208
+ }
9209
+ process$1.stdout.write(`\n${updated} feature(s) baselined.\n`);
9210
+ });
9211
+ const revisionsCommand = new Command("revisions").description("Manage feature revision history").addCommand(baselineCommand);
9212
+
9213
+ //#endregion
9214
+ //#region src/commands/supersede.ts
9215
+ const supersedeCommand = new Command("supersede").description("Mark one feature as superseded by another, setting pointers on both sides").argument("<old-key>", "featureKey being superseded (will be deprecated)").argument("<new-key>", "featureKey that supersedes it").option("-d, --dir <path>", "Directory to scan (default: cwd)").option("--dry-run", "Preview changes without writing").action(async (oldKey, newKey, options) => {
9216
+ const scanDir = resolve(options.dir ?? process$1.cwd());
9217
+ const features = await scanFeatures(scanDir);
9218
+ const byKey = new Map(features.map((f) => [f.feature.featureKey, f]));
9219
+ const oldEntry = byKey.get(oldKey);
9220
+ if (!oldEntry) {
9221
+ process$1.stderr.write(`Error: feature "${oldKey}" not found in "${scanDir}"\n`);
9222
+ process$1.exit(1);
9223
+ }
9224
+ const newEntry = byKey.get(newKey);
9225
+ if (!newEntry) {
9226
+ process$1.stderr.write(`Error: feature "${newKey}" not found in "${scanDir}"\n`);
9227
+ process$1.exit(1);
9228
+ }
9229
+ process$1.stdout.write(`Supersede: ${oldKey} → ${newKey}\n`);
9230
+ process$1.stdout.write(` ${oldKey}: status → deprecated, superseded_by = ${newKey}\n`);
9231
+ process$1.stdout.write(` ${newKey}: superseded_from += [${oldKey}]\n`);
9232
+ if (options.dryRun) {
9233
+ process$1.stdout.write("[dry-run] No changes written.\n");
9234
+ return;
9235
+ }
9236
+ const oldRaw = JSON.parse(await readFile(oldEntry.filePath, "utf-8"));
9237
+ oldRaw.status = "deprecated";
9238
+ oldRaw.superseded_by = newKey;
9239
+ const oldValidation = validateFeature$1(oldRaw);
9240
+ if (!oldValidation.success) {
9241
+ process$1.stderr.write(`Validation error on "${oldKey}": ${oldValidation.errors.join(", ")}\n`);
9242
+ process$1.exit(1);
9243
+ }
9244
+ await writeFile(oldEntry.filePath, JSON.stringify(oldValidation.data, null, 2) + "\n", "utf-8");
9245
+ const newRaw = JSON.parse(await readFile(newEntry.filePath, "utf-8"));
9246
+ const existingSupersededFrom = newRaw.superseded_from ?? [];
9247
+ if (!existingSupersededFrom.includes(oldKey)) newRaw.superseded_from = [...existingSupersededFrom, oldKey];
9248
+ const newValidation = validateFeature$1(newRaw);
9249
+ if (!newValidation.success) {
9250
+ process$1.stderr.write(`Validation error on "${newKey}": ${newValidation.errors.join(", ")}\n`);
9251
+ process$1.exit(1);
9252
+ }
9253
+ await writeFile(newEntry.filePath, JSON.stringify(newValidation.data, null, 2) + "\n", "utf-8");
9254
+ process$1.stdout.write(`✓ ${oldKey} superseded by ${newKey}\n`);
9255
+ });
8211
9256
 
8212
9257
  //#endregion
8213
9258
  //#region src/commands/blame.ts
@@ -8538,748 +9583,906 @@ const doctorCommand = new Command("doctor").description("Check workspace health
8538
9583
  });
8539
9584
 
8540
9585
  //#endregion
8541
- //#region src/templates/markdown.ts
8542
- /**
8543
- * Minimal markdown HTML converter for use in the static site generator.
8544
- * Handles the subset of markdown used in feature.json documentation fields:
8545
- * headings, code blocks, inline code, bold, tables, lists, and paragraphs.
8546
- */
8547
- function escapeHtml$2(s) {
8548
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
8549
- }
8550
- function inlineMarkdown(text) {
8551
- return escapeHtml$2(text).replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>").replace(/`([^`]+)`/g, "<code>$1</code>").replace(/\[([^\]]+)\]\(((?:[^()]*|\([^()]*\))*)\)/g, "<a href=\"$2\">$1</a>");
8552
- }
8553
- function isTableRow(line) {
8554
- return line.trim().startsWith("|") && line.trim().endsWith("|");
8555
- }
8556
- function isTableSeparator(line) {
8557
- return /^\|[\s\-:|]+\|/.test(line.trim());
8558
- }
8559
- function renderTableRow(line, isHeader) {
8560
- const cells = line.trim().slice(1, -1).split("|").map((c) => c.trim());
8561
- const tag = isHeader ? "th" : "td";
8562
- return `<tr>${cells.map((c) => `<${tag}>${inlineMarkdown(c)}</${tag}>`).join("")}</tr>`;
8563
- }
8564
- function markdownToHtml(md) {
8565
- const lines = md.split("\n");
8566
- const out = [];
8567
- let i = 0;
8568
- while (i < lines.length) {
8569
- const line = lines[i] ?? "";
8570
- if (line.startsWith("```")) {
8571
- const lang = line.slice(3).trim();
8572
- const codeLines = [];
8573
- i++;
8574
- while (i < lines.length && !(lines[i] ?? "").startsWith("```")) {
8575
- codeLines.push(lines[i] ?? "");
8576
- i++;
8577
- }
8578
- if (i < lines.length) i++;
8579
- const langAttr = lang ? ` class="language-${escapeHtml$2(lang)}"` : "";
8580
- out.push(`<pre><code${langAttr}>${escapeHtml$2(codeLines.join("\n"))}</code></pre>`);
8581
- continue;
8582
- }
8583
- if (line.startsWith("### ")) {
8584
- out.push(`<h3>${inlineMarkdown(line.slice(4))}</h3>`);
8585
- i++;
8586
- continue;
8587
- }
8588
- if (line.startsWith("## ")) {
8589
- out.push(`<h2>${inlineMarkdown(line.slice(3))}</h2>`);
8590
- i++;
8591
- continue;
8592
- }
8593
- if (line.startsWith("# ")) {
8594
- out.push(`<h1>${inlineMarkdown(line.slice(2))}</h1>`);
8595
- i++;
8596
- continue;
8597
- }
8598
- if (/^(-{3,}|\*{3,}|_{3,})$/.test(line.trim())) {
8599
- out.push("<hr />");
8600
- i++;
8601
- continue;
8602
- }
8603
- if (isTableRow(line)) {
8604
- const tableRows = [];
8605
- let firstRow = true;
8606
- while (i < lines.length && isTableRow(lines[i] ?? "")) {
8607
- const current = lines[i] ?? "";
8608
- if (isTableSeparator(current)) {
8609
- i++;
8610
- continue;
8611
- }
8612
- tableRows.push(renderTableRow(current, firstRow));
8613
- if (firstRow) firstRow = false;
8614
- i++;
8615
- }
8616
- out.push(`<table class="md-table"><thead>${tableRows[0] ?? ""}</thead><tbody>${tableRows.slice(1).join("")}</tbody></table>`);
8617
- continue;
8618
- }
8619
- if (/^[-*] /.test(line)) {
8620
- const items = [];
8621
- while (i < lines.length && /^[-*] /.test(lines[i] ?? "")) {
8622
- items.push(`<li>${inlineMarkdown((lines[i] ?? "").slice(2))}</li>`);
8623
- i++;
8624
- }
8625
- out.push(`<ul>${items.join("")}</ul>`);
8626
- continue;
8627
- }
8628
- if (/^[1-9]\d*\. /.test(line)) {
8629
- const items = [];
8630
- while (i < lines.length && /^[1-9]\d*\. /.test(lines[i] ?? "")) {
8631
- items.push(`<li>${inlineMarkdown((lines[i] ?? "").replace(/^[1-9]\d*\. /, ""))}</li>`);
8632
- i++;
8633
- }
8634
- out.push(`<ol>${items.join("")}</ol>`);
8635
- continue;
8636
- }
8637
- if (line.trim() === "") {
8638
- i++;
8639
- continue;
8640
- }
8641
- const paraLines = [];
8642
- while (i < lines.length && (lines[i] ?? "").trim() !== "" && !(lines[i] ?? "").startsWith("#") && !(lines[i] ?? "").startsWith("```") && !/^[-*] /.test(lines[i] ?? "") && !/^[1-9]\d*\. /.test(lines[i] ?? "") && !isTableRow(lines[i] ?? "")) {
8643
- paraLines.push(lines[i] ?? "");
8644
- i++;
8645
- }
8646
- if (paraLines.length > 0) out.push(`<p>${inlineMarkdown(paraLines.join(" "))}</p>`);
8647
- }
8648
- return out.join("\n");
8649
- }
9586
+ //#region src/lib/htmlGenerator.ts
9587
+ function esc(s) {
9588
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
9589
+ }
9590
+ function generateHtmlWiki(features, projectName) {
9591
+ const dataJson = JSON.stringify(features).replace(/<\/script>/gi, "<\\/script>");
9592
+ features.filter((f) => f.status === "active").length, features.filter((f) => f.status === "frozen").length, features.filter((f) => f.status === "draft").length, features.filter((f) => f.status === "deprecated").length;
9593
+ const domains = [...new Set(features.map((f) => f.domain).filter(Boolean))].sort();
9594
+ return `<!DOCTYPE html>
9595
+ <html lang="en">
9596
+ <head>
9597
+ <meta charset="UTF-8">
9598
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
9599
+ <title>${esc(projectName)} LAC Wiki</title>
9600
+ <style>
9601
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
8650
9602
 
8651
- //#endregion
8652
- //#region src/templates/site-style.css.ts
8653
- const css = `
8654
9603
  :root {
8655
- --color-bg: #ffffff;
8656
- --color-surface: #f8f9fa;
8657
- --color-border: #dee2e6;
8658
- --color-text: #212529;
8659
- --color-text-muted: #6c757d;
8660
- --color-link: #0d6efd;
8661
- --color-link-hover: #0a58ca;
8662
- --color-active: #198754;
8663
- --color-draft: #6c757d;
8664
- --color-frozen: #0d6efd;
8665
- --color-deprecated: #dc3545;
8666
- }
8667
-
8668
- @media (prefers-color-scheme: dark) {
8669
- :root {
8670
- --color-bg: #1a1a2e;
8671
- --color-surface: #16213e;
8672
- --color-border: #374151;
8673
- --color-text: #e9ecef;
8674
- --color-text-muted: #9ca3af;
8675
- --color-link: #60a5fa;
8676
- --color-link-hover: #93c5fd;
8677
- --color-active: #4ade80;
8678
- --color-draft: #9ca3af;
8679
- --color-frozen: #60a5fa;
8680
- --color-deprecated: #f87171;
8681
- }
8682
- }
9604
+ --bg: #12100e;
9605
+ --bg-sidebar: #0e0c0a;
9606
+ --bg-card: #1a1714;
9607
+ --bg-hover: #201d1a;
9608
+ --bg-active: #251f18;
9609
+ --border: #2a2420;
9610
+ --border-soft: #221e1b;
9611
+ --text: #e8ddd4;
9612
+ --text-mid: #b0a49c;
9613
+ --text-soft: #7a6a5a;
9614
+ --accent: #c4a255;
9615
+ --accent-warm: #e8b865;
9616
+ --mono: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Consolas', monospace;
9617
+ --sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
8683
9618
 
8684
- *, *::before, *::after {
8685
- box-sizing: border-box;
8686
- }
9619
+ --status-active: #4aad72;
9620
+ --status-draft: #c4a255;
9621
+ --status-frozen: #5b82cc;
9622
+ --status-deprecated: #cc5b5b;
8687
9623
 
8688
- html {
8689
- font-size: 16px;
8690
- line-height: 1.6;
9624
+ --status-active-bg: rgba(74,173,114,0.12);
9625
+ --status-draft-bg: rgba(196,162,85,0.12);
9626
+ --status-frozen-bg: rgba(91,130,204,0.12);
9627
+ --status-deprecated-bg: rgba(204,91,91,0.12);
8691
9628
  }
8692
9629
 
8693
- body {
8694
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
8695
- Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
8696
- background-color: var(--color-bg);
8697
- color: var(--color-text);
8698
- margin: 0;
8699
- padding: 0;
8700
- }
9630
+ html, body { height: 100%; }
9631
+ body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; line-height: 1.6; display: flex; flex-direction: column; }
8701
9632
 
8702
- .container {
8703
- max-width: 800px;
8704
- margin: 0 auto;
8705
- padding: 2rem 1rem;
8706
- }
9633
+ /* ── Shell ──────────────────────────────────────────────── */
8707
9634
 
8708
- h1 {
8709
- font-size: 2rem;
8710
- font-weight: 700;
8711
- margin-top: 0;
8712
- margin-bottom: 0.5rem;
8713
- }
9635
+ .shell { display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
8714
9636
 
8715
- h2 {
8716
- font-size: 1.25rem;
8717
- font-weight: 600;
8718
- margin-top: 2rem;
8719
- margin-bottom: 0.75rem;
8720
- border-bottom: 1px solid var(--color-border);
8721
- padding-bottom: 0.25rem;
8722
- }
9637
+ .topbar {
9638
+ flex-shrink: 0;
9639
+ height: 44px;
9640
+ display: flex;
9641
+ align-items: center;
9642
+ gap: 12px;
9643
+ padding: 0 18px;
9644
+ background: var(--bg-sidebar);
9645
+ border-bottom: 1px solid var(--border);
9646
+ }
9647
+ .topbar-logo { font-family: var(--mono); font-size: 13px; color: var(--accent); letter-spacing: 0.05em; }
9648
+ .topbar-sep { color: var(--border); }
9649
+ .topbar-project { font-family: var(--mono); font-size: 12px; color: var(--text-mid); }
9650
+ .topbar-count { margin-left: auto; font-family: var(--mono); font-size: 11px; color: var(--text-soft); }
8723
9651
 
8724
- a {
8725
- color: var(--color-link);
8726
- text-decoration: none;
8727
- }
9652
+ .body-row { display: flex; flex: 1; min-height: 0; }
9653
+
9654
+ /* ── Sidebar ────────────────────────────────────────────── */
8728
9655
 
8729
- a:hover {
8730
- color: var(--color-link-hover);
8731
- text-decoration: underline;
9656
+ .sidebar {
9657
+ width: 264px;
9658
+ flex-shrink: 0;
9659
+ background: var(--bg-sidebar);
9660
+ border-right: 1px solid var(--border);
9661
+ display: flex;
9662
+ flex-direction: column;
9663
+ overflow: hidden;
8732
9664
  }
8733
9665
 
8734
- p {
8735
- margin: 0 0 1rem;
9666
+ .sidebar-search {
9667
+ padding: 10px 12px;
9668
+ border-bottom: 1px solid var(--border);
9669
+ flex-shrink: 0;
8736
9670
  }
9671
+ .sidebar-search input {
9672
+ width: 100%;
9673
+ background: var(--bg-card);
9674
+ border: 1px solid var(--border);
9675
+ border-radius: 4px;
9676
+ padding: 6px 10px;
9677
+ font-family: var(--mono);
9678
+ font-size: 12px;
9679
+ color: var(--text);
9680
+ outline: none;
9681
+ }
9682
+ .sidebar-search input:focus { border-color: var(--accent); }
9683
+ .sidebar-search input::placeholder { color: var(--text-soft); }
9684
+
9685
+ .nav-tree {
9686
+ flex: 1;
9687
+ overflow-y: auto;
9688
+ padding: 6px 0 24px;
9689
+ scrollbar-width: thin;
9690
+ scrollbar-color: var(--border) transparent;
9691
+ }
9692
+ .nav-tree::-webkit-scrollbar { width: 4px; }
9693
+ .nav-tree::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
8737
9694
 
8738
- .meta {
9695
+ /* Domain group */
9696
+ .nav-group { margin-bottom: 2px; }
9697
+
9698
+ .nav-domain {
8739
9699
  display: flex;
8740
9700
  align-items: center;
8741
- gap: 0.75rem;
8742
- margin-bottom: 1.5rem;
8743
- color: var(--color-text-muted);
8744
- font-size: 0.875rem;
8745
- }
9701
+ gap: 6px;
9702
+ padding: 6px 14px 4px;
9703
+ font-family: var(--mono);
9704
+ font-size: 10px;
9705
+ letter-spacing: 0.12em;
9706
+ text-transform: uppercase;
9707
+ color: var(--text-soft);
9708
+ cursor: pointer;
9709
+ user-select: none;
9710
+ }
9711
+ .nav-domain:hover { color: var(--text-mid); }
9712
+ .nav-domain-arrow { transition: transform 0.15s; font-size: 8px; }
9713
+ .nav-domain.collapsed .nav-domain-arrow { transform: rotate(-90deg); }
9714
+ .nav-domain-count { margin-left: auto; font-size: 10px; opacity: 0.6; }
8746
9715
 
8747
- .feature-key {
8748
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
8749
- 'Liberation Mono', 'Courier New', monospace;
8750
- font-size: 0.875rem;
8751
- color: var(--color-text-muted);
8752
- }
9716
+ .nav-group-items { overflow: hidden; }
9717
+ .nav-group.collapsed .nav-group-items { display: none; }
8753
9718
 
8754
- /* Status badges */
8755
- .status-badge {
8756
- display: inline-block;
8757
- padding: 0.125rem 0.5rem;
8758
- border-radius: 9999px;
8759
- font-size: 0.75rem;
8760
- font-weight: 600;
8761
- text-transform: uppercase;
8762
- letter-spacing: 0.05em;
9719
+ /* Feature item */
9720
+ .nav-item {
9721
+ display: flex;
9722
+ align-items: baseline;
9723
+ gap: 7px;
9724
+ padding: 5px 14px 5px 18px;
9725
+ cursor: pointer;
9726
+ user-select: none;
9727
+ border-left: 2px solid transparent;
9728
+ transition: background 0.1s;
9729
+ text-decoration: none;
8763
9730
  }
8764
-
8765
- .status-active {
8766
- color: var(--color-active);
8767
- background-color: color-mix(in srgb, var(--color-active) 15%, transparent);
8768
- border: 1px solid color-mix(in srgb, var(--color-active) 30%, transparent);
9731
+ .nav-item:hover { background: var(--bg-hover); }
9732
+ .nav-item.active {
9733
+ background: var(--bg-active);
9734
+ border-left-color: var(--accent);
8769
9735
  }
9736
+ .nav-item[data-depth="1"] { padding-left: 30px; }
9737
+ .nav-item[data-depth="2"] { padding-left: 42px; }
9738
+ .nav-item[data-depth="3"] { padding-left: 54px; }
8770
9739
 
8771
- .status-draft {
8772
- color: var(--color-draft);
8773
- background-color: color-mix(in srgb, var(--color-draft) 15%, transparent);
8774
- border: 1px solid color-mix(in srgb, var(--color-draft) 30%, transparent);
9740
+ .nav-dot {
9741
+ width: 6px;
9742
+ height: 6px;
9743
+ border-radius: 50%;
9744
+ flex-shrink: 0;
9745
+ margin-top: 1px;
8775
9746
  }
9747
+ .nav-item[data-status="active"] .nav-dot { background: var(--status-active); }
9748
+ .nav-item[data-status="draft"] .nav-dot { background: var(--status-draft); }
9749
+ .nav-item[data-status="frozen"] .nav-dot { background: var(--status-frozen); }
9750
+ .nav-item[data-status="deprecated"] .nav-dot { background: var(--status-deprecated); opacity: 0.5; }
8776
9751
 
8777
- .status-frozen {
8778
- color: var(--color-frozen);
8779
- background-color: color-mix(in srgb, var(--color-frozen) 15%, transparent);
8780
- border: 1px solid color-mix(in srgb, var(--color-frozen) 30%, transparent);
8781
- }
9752
+ .nav-item-key {
9753
+ font-family: var(--mono);
9754
+ font-size: 10px;
9755
+ color: var(--text-soft);
9756
+ flex-shrink: 0;
9757
+ }
9758
+ .nav-item-title {
9759
+ font-size: 12px;
9760
+ color: var(--text-mid);
9761
+ white-space: nowrap;
9762
+ overflow: hidden;
9763
+ text-overflow: ellipsis;
9764
+ flex: 1;
9765
+ min-width: 0;
9766
+ }
9767
+ .nav-item:hover .nav-item-title,
9768
+ .nav-item.active .nav-item-title { color: var(--text); }
9769
+ .nav-item.active .nav-item-key { color: var(--accent); }
8782
9770
 
8783
- .status-deprecated {
8784
- color: var(--color-deprecated);
8785
- background-color: color-mix(in srgb, var(--color-deprecated) 15%, transparent);
8786
- border: 1px solid color-mix(in srgb, var(--color-deprecated) 30%, transparent);
9771
+ .nav-child-arrow {
9772
+ font-size: 9px;
9773
+ color: var(--text-soft);
9774
+ flex-shrink: 0;
9775
+ opacity: 0.5;
8787
9776
  }
8788
9777
 
8789
- /* Search */
8790
- .search-wrapper {
8791
- margin-bottom: 1.5rem;
8792
- }
9778
+ /* ── Content ────────────────────────────────────────────── */
8793
9779
 
8794
- .search-input {
8795
- width: 100%;
8796
- padding: 0.5rem 0.75rem;
8797
- border: 1px solid var(--color-border);
8798
- border-radius: 0.375rem;
8799
- background-color: var(--color-surface);
8800
- color: var(--color-text);
8801
- font-size: 1rem;
8802
- font-family: inherit;
8803
- outline: none;
8804
- transition: border-color 0.15s ease;
8805
- }
9780
+ .content {
9781
+ flex: 1;
9782
+ min-width: 0;
9783
+ overflow-y: auto;
9784
+ scrollbar-width: thin;
9785
+ scrollbar-color: var(--border) transparent;
9786
+ }
9787
+ .content::-webkit-scrollbar { width: 6px; }
9788
+ .content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
8806
9789
 
8807
- .search-input:focus {
8808
- border-color: var(--color-link);
9790
+ /* Home / welcome */
9791
+ .home-page {
9792
+ max-width: 680px;
9793
+ margin: 0 auto;
9794
+ padding: 56px 40px 80px;
9795
+ }
9796
+ .home-eyebrow {
9797
+ font-family: var(--mono);
9798
+ font-size: 10px;
9799
+ letter-spacing: 0.18em;
9800
+ text-transform: uppercase;
9801
+ color: var(--accent);
9802
+ margin-bottom: 16px;
9803
+ }
9804
+ .home-title {
9805
+ font-size: 32px;
9806
+ font-weight: 700;
9807
+ color: var(--text);
9808
+ margin-bottom: 8px;
9809
+ line-height: 1.2;
9810
+ }
9811
+ .home-subtitle {
9812
+ font-size: 14px;
9813
+ color: var(--text-mid);
9814
+ margin-bottom: 40px;
8809
9815
  }
8810
9816
 
8811
- /* Feature table */
8812
- .feature-table {
8813
- width: 100%;
8814
- border-collapse: collapse;
8815
- font-size: 0.9375rem;
9817
+ .stat-row {
9818
+ display: flex;
9819
+ gap: 16px;
9820
+ flex-wrap: wrap;
9821
+ margin-bottom: 40px;
8816
9822
  }
9823
+ .stat-pill {
9824
+ display: flex;
9825
+ align-items: center;
9826
+ gap: 8px;
9827
+ padding: 8px 14px;
9828
+ background: var(--bg-card);
9829
+ border: 1px solid var(--border);
9830
+ border-radius: 6px;
9831
+ font-family: var(--mono);
9832
+ font-size: 12px;
9833
+ }
9834
+ .stat-pill-dot { width: 8px; height: 8px; border-radius: 50%; }
9835
+ .stat-pill-num { font-size: 18px; font-weight: 700; color: var(--text); }
9836
+ .stat-pill-label { color: var(--text-soft); }
8817
9837
 
8818
- .feature-table th {
8819
- text-align: left;
8820
- padding: 0.5rem 0.75rem;
8821
- border-bottom: 2px solid var(--color-border);
8822
- color: var(--color-text-muted);
8823
- font-size: 0.75rem;
8824
- font-weight: 600;
9838
+ .home-section-title {
9839
+ font-family: var(--mono);
9840
+ font-size: 10px;
9841
+ letter-spacing: 0.14em;
8825
9842
  text-transform: uppercase;
8826
- letter-spacing: 0.05em;
9843
+ color: var(--text-soft);
9844
+ margin-bottom: 12px;
9845
+ padding-bottom: 6px;
9846
+ border-bottom: 1px solid var(--border);
8827
9847
  }
8828
9848
 
8829
- .feature-table td {
8830
- padding: 0.625rem 0.75rem;
8831
- border-bottom: 1px solid var(--color-border);
8832
- vertical-align: top;
8833
- }
9849
+ .domain-chips {
9850
+ display: flex;
9851
+ flex-wrap: wrap;
9852
+ gap: 8px;
9853
+ margin-bottom: 40px;
9854
+ }
9855
+ .domain-chip {
9856
+ padding: 4px 10px;
9857
+ background: var(--bg-card);
9858
+ border: 1px solid var(--border);
9859
+ border-radius: 100px;
9860
+ font-family: var(--mono);
9861
+ font-size: 11px;
9862
+ color: var(--text-mid);
9863
+ cursor: pointer;
9864
+ transition: border-color 0.15s, color 0.15s;
9865
+ }
9866
+ .domain-chip:hover { border-color: var(--accent); color: var(--accent); }
8834
9867
 
8835
- .feature-table tr:last-child td {
8836
- border-bottom: none;
9868
+ /* Feature page */
9869
+ .feature-page {
9870
+ max-width: 760px;
9871
+ margin: 0 auto;
9872
+ padding: 48px 40px 80px;
8837
9873
  }
8838
9874
 
8839
- .feature-table tr:hover td {
8840
- background-color: var(--color-surface);
9875
+ .feature-meta {
9876
+ display: flex;
9877
+ align-items: center;
9878
+ gap: 8px;
9879
+ flex-wrap: wrap;
9880
+ margin-bottom: 20px;
8841
9881
  }
8842
-
8843
- .problem-excerpt {
8844
- color: var(--color-text-muted);
8845
- font-size: 0.875rem;
9882
+ .feature-key {
9883
+ font-family: var(--mono);
9884
+ font-size: 11px;
9885
+ color: var(--text-soft);
8846
9886
  }
8847
9887
 
8848
- /* Sections */
8849
- section {
8850
- margin-bottom: 2rem;
9888
+ .badge {
9889
+ display: inline-flex;
9890
+ align-items: center;
9891
+ gap: 5px;
9892
+ padding: 3px 8px;
9893
+ border-radius: 4px;
9894
+ font-family: var(--mono);
9895
+ font-size: 11px;
9896
+ font-weight: 500;
8851
9897
  }
9898
+ .badge-dot { width: 6px; height: 6px; border-radius: 50%; }
9899
+ .badge-active { color: var(--status-active); background: var(--status-active-bg); border: 1px solid rgba(74,173,114,0.25); }
9900
+ .badge-draft { color: var(--status-draft); background: var(--status-draft-bg); border: 1px solid rgba(196,162,85,0.25); }
9901
+ .badge-frozen { color: var(--status-frozen); background: var(--status-frozen-bg); border: 1px solid rgba(91,130,204,0.25); }
9902
+ .badge-deprecated { color: var(--status-deprecated); background: var(--status-deprecated-bg); border: 1px solid rgba(204,91,91,0.25); }
8852
9903
 
8853
- .problem-text {
8854
- background-color: var(--color-surface);
8855
- border-left: 3px solid var(--color-link);
8856
- padding: 1rem 1.25rem;
8857
- border-radius: 0 0.375rem 0.375rem 0;
8858
- margin: 0;
9904
+ .badge-domain {
9905
+ color: var(--text-mid);
9906
+ background: var(--bg-card);
9907
+ border: 1px solid var(--border);
8859
9908
  }
8860
9909
 
8861
- /* Decisions timeline */
8862
- ol.decisions {
8863
- list-style: none;
8864
- padding: 0;
8865
- margin: 0;
9910
+ .feature-title {
9911
+ font-size: 26px;
9912
+ font-weight: 700;
9913
+ color: var(--text);
9914
+ line-height: 1.25;
9915
+ margin-bottom: 6px;
8866
9916
  }
8867
-
8868
- ol.decisions li {
9917
+ .feature-completeness {
9918
+ font-family: var(--mono);
9919
+ font-size: 11px;
9920
+ color: var(--text-soft);
9921
+ margin-bottom: 32px;
9922
+ }
9923
+ .completeness-bar {
9924
+ display: inline-block;
9925
+ width: 80px;
9926
+ height: 4px;
9927
+ background: var(--border);
9928
+ border-radius: 2px;
9929
+ vertical-align: middle;
9930
+ margin-right: 6px;
8869
9931
  position: relative;
8870
- padding: 1rem 1rem 1rem 1.5rem;
8871
- border-left: 2px solid var(--color-border);
8872
- margin-bottom: 1rem;
9932
+ top: -1px;
9933
+ overflow: hidden;
8873
9934
  }
8874
-
8875
- ol.decisions li:last-child {
8876
- margin-bottom: 0;
9935
+ .completeness-fill {
9936
+ height: 100%;
9937
+ border-radius: 2px;
9938
+ background: var(--accent);
8877
9939
  }
8878
9940
 
8879
- ol.decisions li::before {
8880
- content: '';
8881
- position: absolute;
8882
- left: -0.375rem;
8883
- top: 1.25rem;
8884
- width: 0.625rem;
8885
- height: 0.625rem;
8886
- border-radius: 50%;
8887
- background-color: var(--color-link);
8888
- }
9941
+ /* Sections */
9942
+ .section { margin-bottom: 36px; }
8889
9943
 
8890
- .decision-date {
8891
- font-size: 0.75rem;
8892
- color: var(--color-text-muted);
8893
- margin-bottom: 0.25rem;
9944
+ .section-header {
9945
+ display: flex;
9946
+ align-items: center;
9947
+ gap: 10px;
9948
+ margin-bottom: 14px;
9949
+ padding-bottom: 8px;
9950
+ border-bottom: 1px solid var(--border);
9951
+ }
9952
+ .section-label {
9953
+ font-family: var(--mono);
9954
+ font-size: 10px;
9955
+ letter-spacing: 0.14em;
9956
+ text-transform: uppercase;
9957
+ color: var(--text-soft);
8894
9958
  }
8895
-
8896
- .decision-text {
8897
- font-weight: 600;
8898
- margin-bottom: 0.375rem;
9959
+ .section-count {
9960
+ font-family: var(--mono);
9961
+ font-size: 10px;
9962
+ color: var(--border);
8899
9963
  }
8900
9964
 
8901
- .decision-rationale {
8902
- color: var(--color-text-muted);
8903
- font-size: 0.9375rem;
8904
- margin-bottom: 0.375rem;
8905
- }
9965
+ .section-body p { color: var(--text-mid); line-height: 1.75; margin-bottom: 10px; }
9966
+ .section-body p:last-child { margin-bottom: 0; }
9967
+ .section-body strong { color: var(--text); }
9968
+ .section-body code {
9969
+ font-family: var(--mono);
9970
+ font-size: 12px;
9971
+ color: var(--accent);
9972
+ background: var(--bg-card);
9973
+ border: 1px solid var(--border);
9974
+ border-radius: 3px;
9975
+ padding: 1px 5px;
9976
+ }
9977
+ .section-body ul { padding-left: 20px; color: var(--text-mid); }
9978
+ .section-body li { margin-bottom: 4px; line-height: 1.6; }
8906
9979
 
8907
- .alternatives {
8908
- font-size: 0.875rem;
8909
- color: var(--color-text-muted);
8910
- }
9980
+ /* Decision cards */
9981
+ .decisions-list { display: flex; flex-direction: column; gap: 12px; }
8911
9982
 
8912
- .alternatives span {
8913
- font-weight: 500;
9983
+ .decision-card {
9984
+ background: var(--bg-card);
9985
+ border: 1px solid var(--border);
9986
+ border-left: 3px solid var(--accent);
9987
+ border-radius: 4px;
9988
+ padding: 14px 16px;
9989
+ }
9990
+ .decision-title {
9991
+ font-size: 13px;
9992
+ font-weight: 600;
9993
+ color: var(--text);
9994
+ margin-bottom: 6px;
9995
+ line-height: 1.4;
8914
9996
  }
8915
-
8916
- /* Implementation / limitation sections */
8917
- .implementation-text {
8918
- white-space: pre-wrap;
8919
- font-size: 0.9375rem;
8920
- line-height: 1.7;
9997
+ .decision-rationale {
9998
+ font-size: 13px;
9999
+ color: var(--text-mid);
10000
+ line-height: 1.65;
10001
+ margin-bottom: 8px;
8921
10002
  }
8922
-
8923
- ul.limitations {
8924
- padding-left: 1.5rem;
8925
- margin: 0;
10003
+ .decision-meta {
10004
+ display: flex;
10005
+ gap: 12px;
10006
+ flex-wrap: wrap;
8926
10007
  }
8927
-
8928
- ul.limitations li {
8929
- margin-bottom: 0.375rem;
8930
- color: var(--color-text-muted);
10008
+ .decision-date {
10009
+ font-family: var(--mono);
10010
+ font-size: 10px;
10011
+ color: var(--text-soft);
8931
10012
  }
8932
-
8933
- /* Lineage */
8934
- .lineage-info {
8935
- background-color: var(--color-surface);
8936
- border: 1px solid var(--color-border);
8937
- border-radius: 0.375rem;
8938
- padding: 1rem 1.25rem;
10013
+ .decision-alts {
10014
+ font-family: var(--mono);
10015
+ font-size: 10px;
10016
+ color: var(--text-soft);
8939
10017
  }
8940
10018
 
8941
- .lineage-info p {
8942
- margin-bottom: 0.5rem;
8943
- }
10019
+ /* Limitations list */
10020
+ .limitations-list { display: flex; flex-direction: column; gap: 6px; }
10021
+ .limitation-item {
10022
+ display: flex;
10023
+ gap: 10px;
10024
+ padding: 8px 12px;
10025
+ background: var(--bg-card);
10026
+ border: 1px solid var(--border);
10027
+ border-radius: 4px;
10028
+ font-size: 13px;
10029
+ color: var(--text-mid);
10030
+ line-height: 1.55;
10031
+ }
10032
+ .limitation-bullet { color: var(--text-soft); flex-shrink: 0; margin-top: 1px; }
8944
10033
 
8945
- .lineage-info p:last-child {
8946
- margin-bottom: 0;
10034
+ /* Tags row */
10035
+ .tags-row { display: flex; flex-wrap: wrap; gap: 6px; }
10036
+ .tag {
10037
+ padding: 3px 9px;
10038
+ background: var(--bg-card);
10039
+ border: 1px solid var(--border);
10040
+ border-radius: 100px;
10041
+ font-family: var(--mono);
10042
+ font-size: 11px;
10043
+ color: var(--text-soft);
8947
10044
  }
8948
10045
 
8949
- /* Back link */
8950
- .back-link {
10046
+ /* Lineage */
10047
+ .lineage-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
10048
+ .lineage-link {
8951
10049
  display: inline-flex;
8952
10050
  align-items: center;
8953
- gap: 0.25rem;
8954
- margin-bottom: 2rem;
8955
- font-size: 0.875rem;
10051
+ gap: 6px;
10052
+ padding: 5px 10px;
10053
+ background: var(--bg-card);
10054
+ border: 1px solid var(--border);
10055
+ border-radius: 4px;
10056
+ font-family: var(--mono);
10057
+ font-size: 11px;
10058
+ color: var(--text-mid);
10059
+ cursor: pointer;
10060
+ transition: border-color 0.15s, color 0.15s;
10061
+ text-decoration: none;
8956
10062
  }
10063
+ .lineage-link:hover { border-color: var(--accent); color: var(--accent); }
10064
+ .lineage-arrow { color: var(--text-soft); font-size: 10px; }
10065
+
10066
+ /* Empty states */
10067
+ .empty { color: var(--text-soft); font-size: 13px; font-style: italic; }
8957
10068
 
8958
- /* Empty state */
8959
- .empty-state {
10069
+ /* No results */
10070
+ .no-results {
10071
+ padding: 24px 18px;
10072
+ font-size: 12px;
10073
+ color: var(--text-soft);
8960
10074
  text-align: center;
8961
- padding: 3rem 1rem;
8962
- color: var(--color-text-muted);
10075
+ font-family: var(--mono);
8963
10076
  }
10077
+ </style>
10078
+ </head>
10079
+ <body>
10080
+ <div class="shell">
10081
+ <div class="topbar">
10082
+ <span class="topbar-logo">◈ lac</span>
10083
+ <span class="topbar-sep">|</span>
10084
+ <span class="topbar-project">${esc(projectName)}</span>
10085
+ <span class="topbar-count">${features.length} features · ${domains.length} domains</span>
10086
+ </div>
10087
+ <div class="body-row">
10088
+ <aside class="sidebar">
10089
+ <div class="sidebar-search">
10090
+ <input type="text" id="filter-input" placeholder="Filter features…" autocomplete="off" spellcheck="false">
10091
+ </div>
10092
+ <nav class="nav-tree" id="nav-tree"></nav>
10093
+ </aside>
10094
+ <main class="content" id="content"></main>
10095
+ </div>
10096
+ </div>
8964
10097
 
8965
- /* No results row */
8966
- .no-results {
8967
- display: none;
8968
- padding: 1.5rem 0.75rem;
8969
- color: var(--color-text-muted);
8970
- font-style: italic;
10098
+ <script>
10099
+ const FEATURES = ${dataJson};
10100
+
10101
+ // ── Helpers ──────────────────────────────────────────────────────────────────
10102
+
10103
+ function esc(s) {
10104
+ return String(s)
10105
+ .replace(/&/g, '&amp;')
10106
+ .replace(/</g, '&lt;')
10107
+ .replace(/>/g, '&gt;')
10108
+ .replace(/"/g, '&quot;');
8971
10109
  }
8972
10110
 
8973
- /* Markdown-rendered content */
8974
- .implementation-text h2,
8975
- .analysis-text h2 {
8976
- font-size: 1.125rem;
8977
- font-weight: 600;
8978
- margin-top: 1.75rem;
8979
- margin-bottom: 0.5rem;
8980
- border-bottom: 1px solid var(--color-border);
8981
- padding-bottom: 0.25rem;
10111
+ function md(text) {
10112
+ if (!text) return '';
10113
+ const lines = esc(text).split('\\n');
10114
+ const out = [];
10115
+ let inList = false;
10116
+ for (const line of lines) {
10117
+ const t = line.trim();
10118
+ if (t.match(/^[-*] /)) {
10119
+ if (!inList) { out.push('<ul>'); inList = true; }
10120
+ out.push('<li>' + inline(t.slice(2)) + '</li>');
10121
+ } else {
10122
+ if (inList) { out.push('</ul>'); inList = false; }
10123
+ out.push(line);
10124
+ }
10125
+ }
10126
+ if (inList) out.push('</ul>');
10127
+ return out.join('\\n').split(/\\n{2,}/).map(block => {
10128
+ const b = block.trim();
10129
+ if (!b) return '';
10130
+ if (b.startsWith('<ul>') || b.startsWith('<li>')) return b;
10131
+ return '<p>' + inline(b.replace(/\\n/g, '<br>')) + '</p>';
10132
+ }).filter(Boolean).join('\\n');
8982
10133
  }
8983
10134
 
8984
- .implementation-text h3,
8985
- .analysis-text h3 {
8986
- font-size: 1rem;
8987
- font-weight: 600;
8988
- margin-top: 1.25rem;
8989
- margin-bottom: 0.375rem;
8990
- color: var(--color-text-muted);
10135
+ function inline(s) {
10136
+ return s
10137
+ .replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
10138
+ .replace(/\`([^\`\\n]+)\`/g, '<code>$1</code>');
8991
10139
  }
8992
10140
 
8993
- .implementation-text pre,
8994
- .analysis-text pre {
8995
- background-color: var(--color-surface);
8996
- border: 1px solid var(--color-border);
8997
- border-radius: 0.375rem;
8998
- padding: 1rem 1.25rem;
8999
- overflow-x: auto;
9000
- margin: 1rem 0;
10141
+ function completeness(f) {
10142
+ const checks = [
10143
+ !!f.analysis, !!f.implementation,
10144
+ !!(f.decisions && f.decisions.length),
10145
+ !!f.successCriteria,
10146
+ !!(f.knownLimitations && f.knownLimitations.length),
10147
+ !!(f.tags && f.tags.length),
10148
+ !!f.domain,
10149
+ ];
10150
+ return Math.round(checks.filter(Boolean).length / checks.length * 100);
9001
10151
  }
9002
10152
 
9003
- .implementation-text code,
9004
- .analysis-text code {
9005
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
9006
- font-size: 0.875em;
10153
+ function statusColor(s) {
10154
+ return { active: '#4aad72', draft: '#c4a255', frozen: '#5b82cc', deprecated: '#cc5b5b' }[s] || '#c4a255';
9007
10155
  }
9008
10156
 
9009
- .implementation-text pre code,
9010
- .analysis-text pre code {
9011
- background: none;
9012
- padding: 0;
9013
- border-radius: 0;
9014
- font-size: 0.875rem;
10157
+ // ── Tree building ────────────────────────────────────────────────────────────
10158
+
10159
+ const byKey = new Map(FEATURES.map(f => [f.featureKey, f]));
10160
+
10161
+ function getChildren(key) {
10162
+ return FEATURES.filter(f => f.lineage && f.lineage.parent === key);
9015
10163
  }
9016
10164
 
9017
- .implementation-text :not(pre) > code,
9018
- .analysis-text :not(pre) > code {
9019
- background-color: var(--color-surface);
9020
- border: 1px solid var(--color-border);
9021
- border-radius: 0.25rem;
9022
- padding: 0.1em 0.35em;
10165
+ function isRoot(f) {
10166
+ const p = f.lineage && f.lineage.parent;
10167
+ return !p || !byKey.has(p);
9023
10168
  }
9024
10169
 
9025
- .implementation-text ul,
9026
- .analysis-text ul,
9027
- .implementation-text ol,
9028
- .analysis-text ol {
9029
- padding-left: 1.5rem;
9030
- margin: 0.5rem 0 1rem;
10170
+ /** Flatten feature + its descendants with depth info */
10171
+ function flatten(f, depth) {
10172
+ const result = [{ feature: f, depth }];
10173
+ for (const child of getChildren(f.featureKey)) {
10174
+ result.push(...flatten(child, depth + 1));
10175
+ }
10176
+ return result;
9031
10177
  }
9032
10178
 
9033
- .implementation-text li,
9034
- .analysis-text li {
9035
- margin-bottom: 0.25rem;
10179
+ // Group root features by domain
10180
+ function buildGroups(features) {
10181
+ const roots = features.filter(isRoot);
10182
+ const groups = new Map();
10183
+
10184
+ for (const f of roots) {
10185
+ const domain = f.domain || '(no domain)';
10186
+ if (!groups.has(domain)) groups.set(domain, []);
10187
+ groups.get(domain).push(...flatten(f, 0));
10188
+ }
10189
+ return groups;
9036
10190
  }
9037
10191
 
9038
- /* Markdown tables */
9039
- .md-table {
9040
- width: 100%;
9041
- border-collapse: collapse;
9042
- margin: 1rem 0;
9043
- font-size: 0.9rem;
10192
+ // ── Nav rendering ────────────────────────────────────────────────────────────
10193
+
10194
+ let activeKey = null;
10195
+ const collapsedDomains = new Set();
10196
+
10197
+ function renderNav(filterText) {
10198
+ const nav = document.getElementById('nav-tree');
10199
+ const q = (filterText || '').toLowerCase().trim();
10200
+
10201
+ const features = q
10202
+ ? FEATURES.filter(f =>
10203
+ f.featureKey.toLowerCase().includes(q) ||
10204
+ f.title.toLowerCase().includes(q) ||
10205
+ (f.domain && f.domain.toLowerCase().includes(q)) ||
10206
+ (f.tags && f.tags.some(t => t.toLowerCase().includes(q)))
10207
+ )
10208
+ : FEATURES;
10209
+
10210
+ if (features.length === 0) {
10211
+ nav.innerHTML = '<div class="no-results">No features match</div>';
10212
+ return;
10213
+ }
10214
+
10215
+ const groups = q
10216
+ ? (() => {
10217
+ // Flat list when searching
10218
+ const m = new Map([['results', features.map(f => ({ feature: f, depth: 0 }))]]);
10219
+ return m;
10220
+ })()
10221
+ : buildGroups(FEATURES);
10222
+
10223
+ const sortedDomains = [...groups.keys()].sort((a, b) => {
10224
+ if (a === '(no domain)') return 1;
10225
+ if (b === '(no domain)') return -1;
10226
+ return a.localeCompare(b);
10227
+ });
10228
+
10229
+ let html = '';
10230
+ for (const domain of sortedDomains) {
10231
+ const items = groups.get(domain);
10232
+ const visible = q ? items.filter(({ feature: f }) =>
10233
+ f.featureKey.toLowerCase().includes(q) ||
10234
+ f.title.toLowerCase().includes(q) ||
10235
+ (f.tags && f.tags.some(t => t.toLowerCase().includes(q)))
10236
+ ) : items;
10237
+ if (visible.length === 0) continue;
10238
+
10239
+ const isCollapsed = !q && collapsedDomains.has(domain);
10240
+ const label = domain === 'results' ? 'results' : domain;
10241
+
10242
+ html += \`<div class="nav-group\${isCollapsed ? ' collapsed' : ''}" data-domain="\${esc(domain)}">
10243
+ <div class="nav-domain\${isCollapsed ? ' collapsed' : ''}" onclick="toggleDomain(this)">
10244
+ <span class="nav-domain-arrow">▾</span>
10245
+ <span>\${esc(label)}</span>
10246
+ <span class="nav-domain-count">\${visible.length}</span>
10247
+ </div>
10248
+ <div class="nav-group-items">\`;
10249
+
10250
+ for (const { feature: f, depth } of visible) {
10251
+ const isChild = depth > 0;
10252
+ const hasChildren = getChildren(f.featureKey).length > 0;
10253
+ html += \`<div class="nav-item\${f.featureKey === activeKey ? ' active' : ''}"
10254
+ data-key="\${esc(f.featureKey)}"
10255
+ data-status="\${esc(f.status)}"
10256
+ data-depth="\${depth}"
10257
+ onclick="navigate('\${esc(f.featureKey)}')">
10258
+ \${isChild ? '<span class="nav-child-arrow">↳</span>' : '<span class="nav-dot"></span>'}
10259
+ <span class="nav-item-key">\${esc(f.featureKey)}</span>
10260
+ <span class="nav-item-title">\${esc(f.title)}</span>
10261
+ \${hasChildren ? '<span class="nav-child-arrow" style="margin-left:auto;opacity:0.4">⊕</span>' : ''}
10262
+ </div>\`;
10263
+ }
10264
+
10265
+ html += '</div></div>';
10266
+ }
10267
+
10268
+ nav.innerHTML = html;
9044
10269
  }
9045
10270
 
9046
- .md-table th {
9047
- text-align: left;
9048
- padding: 0.5rem 0.75rem;
9049
- border-bottom: 2px solid var(--color-border);
9050
- font-size: 0.75rem;
9051
- font-weight: 600;
9052
- text-transform: uppercase;
9053
- letter-spacing: 0.05em;
9054
- color: var(--color-text-muted);
10271
+ function toggleDomain(el) {
10272
+ const group = el.closest('.nav-group');
10273
+ const domain = group.dataset.domain;
10274
+ if (collapsedDomains.has(domain)) {
10275
+ collapsedDomains.delete(domain);
10276
+ group.classList.remove('collapsed');
10277
+ el.classList.remove('collapsed');
10278
+ } else {
10279
+ collapsedDomains.add(domain);
10280
+ group.classList.add('collapsed');
10281
+ el.classList.add('collapsed');
10282
+ }
9055
10283
  }
9056
10284
 
9057
- .md-table td {
9058
- padding: 0.5rem 0.75rem;
9059
- border-bottom: 1px solid var(--color-border);
9060
- vertical-align: top;
10285
+ // ── Content rendering ────────────────────────────────────────────────────────
10286
+
10287
+ function renderHome() {
10288
+ const content = document.getElementById('content');
10289
+ const total = FEATURES.length;
10290
+ const frozen = FEATURES.filter(f => f.status === 'frozen').length;
10291
+ const active = FEATURES.filter(f => f.status === 'active').length;
10292
+ const draft = FEATURES.filter(f => f.status === 'draft').length;
10293
+ const depr = FEATURES.filter(f => f.status === 'deprecated').length;
10294
+
10295
+ const domains = [...new Set(FEATURES.map(f => f.domain).filter(Boolean))].sort();
10296
+
10297
+ const avgCompleteness = FEATURES.length
10298
+ ? Math.round(FEATURES.reduce((s, f) => s + completeness(f), 0) / FEATURES.length)
10299
+ : 0;
10300
+
10301
+ content.innerHTML = \`<div class="home-page">
10302
+ <div class="home-eyebrow">◈ life-as-code wiki</div>
10303
+ <div class="home-title">\${esc(document.title.replace(' — LAC Wiki', ''))}</div>
10304
+ <div class="home-subtitle">\${total} feature\${total === 1 ? '' : 's'} · avg \${avgCompleteness}% complete</div>
10305
+
10306
+ <div class="stat-row">
10307
+ \${active ? \`<div class="stat-pill"><span class="stat-pill-dot" style="background:#4aad72"></span><span class="stat-pill-num">\${active}</span><span class="stat-pill-label">active</span></div>\` : ''}
10308
+ \${frozen ? \`<div class="stat-pill"><span class="stat-pill-dot" style="background:#5b82cc"></span><span class="stat-pill-num">\${frozen}</span><span class="stat-pill-label">frozen</span></div>\` : ''}
10309
+ \${draft ? \`<div class="stat-pill"><span class="stat-pill-dot" style="background:#c4a255"></span><span class="stat-pill-num">\${draft}</span><span class="stat-pill-label">draft</span></div>\` : ''}
10310
+ \${depr ? \`<div class="stat-pill"><span class="stat-pill-dot" style="background:#cc5b5b"></span><span class="stat-pill-num">\${depr}</span><span class="stat-pill-label">deprecated</span></div>\` : ''}
10311
+ </div>
10312
+
10313
+ \${domains.length ? \`<div class="home-section-title">Domains</div>
10314
+ <div class="domain-chips">
10315
+ \${domains.map(d => \`<span class="domain-chip" onclick="filterByDomain('\${esc(d)}')">\${esc(d)}</span>\`).join('')}
10316
+ </div>\` : ''}
10317
+
10318
+ <div class="home-section-title">All features</div>
10319
+ <div style="display:flex;flex-direction:column;gap:2px;">
10320
+ \${FEATURES.map(f => \`<div class="nav-item" data-key="\${esc(f.featureKey)}" data-status="\${esc(f.status)}" data-depth="0" onclick="navigate('\${esc(f.featureKey)}')" style="border-radius:4px;border:1px solid var(--border-soft);margin-bottom:2px;">
10321
+ <span class="nav-dot"></span>
10322
+ <span class="nav-item-key">\${esc(f.featureKey)}</span>
10323
+ <span class="nav-item-title">\${esc(f.title)}</span>
10324
+ <span style="margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--text-soft);">\${completeness(f)}%</span>
10325
+ </div>\`).join('')}
10326
+ </div>
10327
+ </div>\`;
9061
10328
  }
9062
10329
 
9063
- .md-table tr:last-child td {
9064
- border-bottom: none;
10330
+ function renderFeature(key) {
10331
+ const f = byKey.get(key);
10332
+ if (!f) { renderHome(); return; }
10333
+
10334
+ const pct = completeness(f);
10335
+ const barFill = \`<span class="completeness-bar"><span class="completeness-fill" style="width:\${pct}%"></span></span>\`;
10336
+
10337
+ const children = getChildren(f.featureKey);
10338
+ const parent = f.lineage && f.lineage.parent && byKey.get(f.lineage.parent);
10339
+
10340
+ let html = \`<div class="feature-page">
10341
+ <div class="feature-meta">
10342
+ <span class="feature-key">\${esc(f.featureKey)}</span>
10343
+ <span class="badge badge-\${esc(f.status)}"><span class="badge-dot" style="background:\${statusColor(f.status)}"></span>\${esc(f.status)}</span>
10344
+ \${f.domain ? \`<span class="badge badge-domain">\${esc(f.domain)}</span>\` : ''}
10345
+ </div>
10346
+ <div class="feature-title">\${esc(f.title)}</div>
10347
+ <div class="feature-completeness">\${barFill}\${pct}% complete</div>\`;
10348
+
10349
+ // Problem
10350
+ html += section('Problem', f.problem ? \`<div class="section-body">\${md(f.problem)}</div>\` : '<span class="empty">Not documented.</span>');
10351
+
10352
+ // Analysis
10353
+ if (f.analysis)
10354
+ html += section('Analysis', \`<div class="section-body">\${md(f.analysis)}</div>\`);
10355
+
10356
+ // Implementation
10357
+ if (f.implementation)
10358
+ html += section('Implementation', \`<div class="section-body">\${md(f.implementation)}</div>\`);
10359
+
10360
+ // Success Criteria
10361
+ if (f.successCriteria)
10362
+ html += section('Success Criteria', \`<div class="section-body">\${md(f.successCriteria)}</div>\`);
10363
+
10364
+ // Decisions
10365
+ if (f.decisions && f.decisions.length) {
10366
+ const cards = f.decisions.map(d => \`<div class="decision-card">
10367
+ <div class="decision-title">\${esc(d.decision)}</div>
10368
+ <div class="decision-rationale">\${md(d.rationale)}</div>
10369
+ \${d.date || (d.alternativesConsidered && d.alternativesConsidered.length) ? \`<div class="decision-meta">
10370
+ \${d.date ? \`<span class="decision-date">📅 \${esc(d.date)}</span>\` : ''}
10371
+ \${d.alternativesConsidered && d.alternativesConsidered.length ? \`<span class="decision-alts">Considered: \${d.alternativesConsidered.map(esc).join(', ')}</span>\` : ''}
10372
+ </div>\` : ''}
10373
+ </div>\`).join('');
10374
+ html += section('Decisions', \`<div class="decisions-list">\${cards}</div>\`, f.decisions.length);
10375
+ }
10376
+
10377
+ // Known Limitations
10378
+ if (f.knownLimitations && f.knownLimitations.length) {
10379
+ const items = f.knownLimitations.map(l =>
10380
+ \`<div class="limitation-item"><span class="limitation-bullet">—</span><span>\${md(l)}</span></div>\`
10381
+ ).join('');
10382
+ html += section('Known Limitations', \`<div class="limitations-list">\${items}</div>\`, f.knownLimitations.length);
10383
+ }
10384
+
10385
+ // Tags
10386
+ if (f.tags && f.tags.length) {
10387
+ const chips = f.tags.map(t => \`<span class="tag">\${esc(t)}</span>\`).join('');
10388
+ html += section('Tags', \`<div class="tags-row">\${chips}</div>\`);
10389
+ }
10390
+
10391
+ // Lineage
10392
+ if (parent || children.length) {
10393
+ let lineage = '<div class="lineage-row">';
10394
+ if (parent) {
10395
+ lineage += \`<span class="lineage-arrow">parent ↑</span>
10396
+ <a class="lineage-link" onclick="navigate('\${esc(parent.featureKey)}')">
10397
+ \${esc(parent.featureKey)} — \${esc(parent.title)}
10398
+ </a>\`;
10399
+ }
10400
+ if (parent && children.length) lineage += '<span class="lineage-arrow" style="margin:0 6px;">·</span>';
10401
+ if (children.length) {
10402
+ lineage += '<span class="lineage-arrow">children ↓</span>';
10403
+ for (const c of children) {
10404
+ lineage += \`<a class="lineage-link" onclick="navigate('\${esc(c.featureKey)}')">
10405
+ \${esc(c.featureKey)} — \${esc(c.title)}
10406
+ </a>\`;
10407
+ }
10408
+ }
10409
+ lineage += '</div>';
10410
+ html += section('Lineage', lineage);
10411
+ }
10412
+
10413
+ html += '</div>';
10414
+ document.getElementById('content').innerHTML = html;
10415
+ document.getElementById('content').scrollTop = 0;
9065
10416
  }
9066
10417
 
9067
- .md-table tr:hover td {
9068
- background-color: var(--color-surface);
10418
+ function section(label, body, count) {
10419
+ const countHtml = count != null ? \`<span class="section-count">(\${count})</span>\` : '';
10420
+ return \`<div class="section">
10421
+ <div class="section-header">
10422
+ <span class="section-label">\${label}</span>\${countHtml}
10423
+ </div>
10424
+ \${body}
10425
+ </div>\`;
9069
10426
  }
9070
10427
 
9071
- /* Analysis section */
9072
- .analysis-text {
9073
- font-size: 0.9375rem;
9074
- line-height: 1.7;
10428
+ // ── Navigation ───────────────────────────────────────────────────────────────
10429
+
10430
+ function navigate(key) {
10431
+ activeKey = key;
10432
+ location.hash = key ? '#' + key : '';
10433
+ renderNav(document.getElementById('filter-input').value);
10434
+ if (key) {
10435
+ renderFeature(key);
10436
+ // Scroll nav item into view
10437
+ const el = document.querySelector(\`.nav-item[data-key="\${key}"]\`);
10438
+ if (el) el.scrollIntoView({ block: 'nearest' });
10439
+ } else {
10440
+ renderHome();
10441
+ }
9075
10442
  }
9076
- `;
9077
10443
 
9078
- //#endregion
9079
- //#region src/templates/site-feature.html.ts
9080
- function escapeHtml$1(str) {
9081
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
9082
- }
9083
- function statusBadge$1(status) {
9084
- return `<span class="status-badge status-${escapeHtml$1(status)}">${escapeHtml$1(status)}</span>`;
9085
- }
9086
- function renderDecisions(decisions) {
9087
- if (decisions.length === 0) return "";
9088
- return `
9089
- <section class="decisions">
9090
- <h2>Decisions</h2>
9091
- <ol class="decisions">
9092
- ${decisions.map((d) => {
9093
- const date$4 = d.date ? `<div class="decision-date">${escapeHtml$1(d.date)}</div>` : "";
9094
- const alts = d.alternativesConsidered && d.alternativesConsidered.length > 0 ? `<div class="alternatives"><span>Alternatives considered:</span> ${d.alternativesConsidered.map(escapeHtml$1).join(", ")}</div>` : "";
9095
- return `
9096
- <li>
9097
- ${date$4}
9098
- <div class="decision-text">${escapeHtml$1(d.decision)}</div>
9099
- <div class="decision-rationale">${escapeHtml$1(d.rationale)}</div>
9100
- ${alts}
9101
- </li>`;
9102
- }).join("\n")}
9103
- </ol>
9104
- </section>`;
9105
- }
9106
- function renderLineage(lineage) {
9107
- const parts = [];
9108
- if (lineage.parent) parts.push(`<p><strong>Parent:</strong> <a href="${escapeHtml$1(lineage.parent)}.html">${escapeHtml$1(lineage.parent)}</a></p>`);
9109
- if (lineage.children && lineage.children.length > 0) {
9110
- const childLinks = lineage.children.map((c) => `<a href="${escapeHtml$1(c)}.html">${escapeHtml$1(c)}</a>`).join(", ");
9111
- parts.push(`<p><strong>Children:</strong> ${childLinks}</p>`);
9112
- }
9113
- if (lineage.spawnReason) parts.push(`<p><strong>Spawn reason:</strong> ${escapeHtml$1(lineage.spawnReason)}</p>`);
9114
- if (parts.length === 0) return "";
9115
- return `
9116
- <section class="lineage">
9117
- <h2>Lineage</h2>
9118
- <div class="lineage-info">
9119
- ${parts.join("\n ")}
9120
- </div>
9121
- </section>`;
9122
- }
9123
- function renderFeature(feature) {
9124
- const decisionsSection = feature.decisions && feature.decisions.length > 0 ? renderDecisions(feature.decisions) : "";
9125
- const implementationSection = feature.implementation ? `
9126
- <section class="implementation">
9127
- <h2>How it works</h2>
9128
- <div class="implementation-text">${markdownToHtml(feature.implementation)}</div>
9129
- </section>` : "";
9130
- const analysisSection = feature["analysis"] ? `
9131
- <section class="analysis">
9132
- <h2>Background &amp; Context</h2>
9133
- <div class="analysis-text">${markdownToHtml(feature["analysis"])}</div>
9134
- </section>` : "";
9135
- const limitationsSection = feature.knownLimitations && feature.knownLimitations.length > 0 ? `
9136
- <section class="limitations">
9137
- <h2>Known Limitations</h2>
9138
- <ul class="limitations">
9139
- ${feature.knownLimitations.map((l) => `<li>${escapeHtml$1(l)}</li>`).join("\n ")}
9140
- </ul>
9141
- </section>` : "";
9142
- const lineageSection = feature.lineage && (feature.lineage.parent || feature.lineage.children && feature.lineage.children.length > 0 || feature.lineage.spawnReason) ? renderLineage(feature.lineage) : "";
9143
- const tagsSection = feature.tags && feature.tags.length > 0 ? `<div class="meta" style="margin-top:0.5rem;flex-wrap:wrap">${feature.tags.map((t) => `<span style="font-size:0.75rem;padding:0.125rem 0.5rem;border-radius:9999px;background-color:var(--color-surface);border:1px solid var(--color-border)">${escapeHtml$1(t)}</span>`).join("")}</div>` : "";
9144
- return `<!DOCTYPE html>
9145
- <html lang="en">
9146
- <head>
9147
- <meta charset="UTF-8" />
9148
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
9149
- <title>${escapeHtml$1(feature.title)} — Feature Provenance</title>
9150
- <style>${css}</style>
9151
- </head>
9152
- <body>
9153
- <div class="container">
9154
- <a href="index.html" class="back-link">&#8592; All features</a>
9155
-
9156
- <h1>${escapeHtml$1(feature.title)}</h1>
9157
- <div class="meta">
9158
- ${statusBadge$1(feature.status)}
9159
- <span class="feature-key">${escapeHtml$1(feature.featureKey)}</span>
9160
- </div>
9161
- ${tagsSection}
9162
-
9163
- <section class="problem">
9164
- <h2>Problem</h2>
9165
- <p class="problem-text">${escapeHtml$1(feature.problem)}</p>
9166
- </section>
9167
-
9168
- ${analysisSection}
9169
- ${decisionsSection}
9170
- ${implementationSection}
9171
- ${limitationsSection}
9172
- ${lineageSection}
9173
- </div>
9174
- </body>
9175
- </html>`;
10444
+ function filterByDomain(domain) {
10445
+ const input = document.getElementById('filter-input');
10446
+ input.value = domain;
10447
+ renderNav(domain);
9176
10448
  }
9177
10449
 
9178
- //#endregion
9179
- //#region src/templates/site-index.html.ts
9180
- function escapeHtml(str) {
9181
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
9182
- }
9183
- function statusBadge(status) {
9184
- return `<span class="status-badge status-${escapeHtml(status)}">${escapeHtml(status)}</span>`;
9185
- }
9186
- function renderIndex(features, generatedAt) {
9187
- const timestamp = (generatedAt ?? /* @__PURE__ */ new Date()).toISOString();
9188
- const rows = features.length === 0 ? `<tr><td colspan="4" class="no-results" style="display:table-cell">No features found.</td></tr>` : features.map((f) => `
9189
- <tr class="feature-row" data-search="${escapeHtml((f.featureKey + " " + f.title).toLowerCase())}">
9190
- <td><a href="${escapeHtml(f.featureKey)}.html" class="feature-key">${escapeHtml(f.featureKey)}</a></td>
9191
- <td><a href="${escapeHtml(f.featureKey)}.html">${escapeHtml(f.title)}</a></td>
9192
- <td>${statusBadge(f.status)}</td>
9193
- <td class="problem-excerpt">${escapeHtml(f.problem.slice(0, 100))}${f.problem.length > 100 ? "…" : ""}</td>
9194
- </tr>`).join("\n");
9195
- return `<!DOCTYPE html>
9196
- <html lang="en">
9197
- <head>
9198
- <meta charset="UTF-8" />
9199
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
9200
- <title>Feature Provenance</title>
9201
- <style>${css}</style>
9202
- </head>
9203
- <body>
9204
- <div class="container">
9205
- <h1>Feature Provenance</h1>
9206
- <p style="color:var(--color-text-muted);margin-bottom:1.5rem">${features.length} feature${features.length === 1 ? "" : "s"} tracked</p>
9207
-
9208
- <div class="search-wrapper">
9209
- <input
9210
- type="search"
9211
- id="search"
9212
- class="search-input"
9213
- placeholder="Search by key or title…"
9214
- aria-label="Search features"
9215
- />
9216
- </div>
9217
-
9218
- <table class="feature-table" id="feature-table">
9219
- <thead>
9220
- <tr>
9221
- <th>Key</th>
9222
- <th>Title</th>
9223
- <th>Status</th>
9224
- <th>Problem</th>
9225
- </tr>
9226
- </thead>
9227
- <tbody id="feature-tbody">
9228
- ${rows}
9229
- <tr id="no-results-row" style="display:none">
9230
- <td colspan="4" class="no-results" style="display:table-cell;color:var(--color-text-muted);font-style:italic;padding:1.5rem 0.75rem">No features match your search.</td>
9231
- </tr>
9232
- </tbody>
9233
- </table>
9234
- </div>
10450
+ // Filter input
10451
+ document.getElementById('filter-input').addEventListener('input', e => {
10452
+ renderNav(e.target.value);
10453
+ });
9235
10454
 
9236
- <footer style="margin-top:2rem;padding-top:1rem;border-top:1px solid var(--color-border);color:var(--color-text-muted);font-size:0.75rem;text-align:right">
9237
- Generated ${escapeHtml(timestamp)}
9238
- </footer>
9239
-
9240
- <script>
9241
- (function () {
9242
- var input = document.getElementById('search');
9243
- var noResults = document.getElementById('no-results-row');
9244
- if (!input) return;
9245
- input.addEventListener('input', function () {
9246
- var query = input.value.trim().toLowerCase();
9247
- var rows = document.querySelectorAll('#feature-tbody .feature-row');
9248
- var visible = 0;
9249
- rows.forEach(function (row) {
9250
- var search = row.getAttribute('data-search') || '';
9251
- var match = query === '' || search.indexOf(query) !== -1;
9252
- row.style.display = match ? '' : 'none';
9253
- if (match) visible++;
9254
- });
9255
- if (noResults) {
9256
- noResults.style.display = (visible === 0 && query !== '') ? '' : 'none';
9257
- }
9258
- });
9259
- })();
9260
- <\/script>
10455
+ // ── Boot ─────────────────────────────────────────────────────────────────────
10456
+
10457
+ const hashKey = location.hash.slice(1);
10458
+ if (hashKey && byKey.has(hashKey)) {
10459
+ activeKey = hashKey;
10460
+ renderNav('');
10461
+ renderFeature(hashKey);
10462
+ setTimeout(() => {
10463
+ const el = document.querySelector(\`.nav-item[data-key="\${hashKey}"]\`);
10464
+ if (el) el.scrollIntoView({ block: 'nearest' });
10465
+ }, 0);
10466
+ } else {
10467
+ renderNav('');
10468
+ renderHome();
10469
+ }
10470
+
10471
+ window.navigate = navigate;
10472
+ window.toggleDomain = toggleDomain;
10473
+ window.filterByDomain = filterByDomain;
10474
+ <\/script>
9261
10475
  </body>
9262
10476
  </html>`;
9263
10477
  }
9264
10478
 
9265
10479
  //#endregion
9266
10480
  //#region src/lib/siteGenerator.ts
9267
- /**
9268
- * Generates a static HTML site for the given features.
9269
- * Creates:
9270
- * outDir/index.html — searchable feature list
9271
- * outDir/{featureKey}.html — one page per feature
9272
- * outDir/style.css — shared stylesheet
9273
- */
9274
10481
  async function generateSite(features, outDir) {
9275
10482
  await mkdir(outDir, { recursive: true });
9276
- await writeFile(join(outDir, "style.css"), css.trim(), "utf-8");
9277
- const allFeatures = features.map((f) => f.feature);
9278
- await writeFile(join(outDir, "index.html"), renderIndex(allFeatures), "utf-8");
9279
- for (const { feature } of features) {
9280
- const pageHtml = renderFeature(feature);
9281
- await writeFile(join(outDir, `${feature.featureKey}.html`), pageHtml, "utf-8");
9282
- }
10483
+ const projectName = basename(outDir.replace(/[/\\]+$/, "")) || "project";
10484
+ const html = generateHtmlWiki(features.map((f) => f.feature), projectName);
10485
+ await writeFile(join(outDir, "index.html"), html, "utf-8");
9283
10486
  }
9284
10487
 
9285
10488
  //#endregion
@@ -9358,7 +10561,199 @@ function featureToMarkdown(feature) {
9358
10561
  }
9359
10562
  return lines.join("\n");
9360
10563
  }
9361
- const exportCommand = new Command("export").description("Export feature.json as JSON, Markdown, or generate a static HTML site").option("--out <path>", "Output file or directory path").option("--site <dir>", "Scan <dir> for feature.json files and generate a static HTML site").option("--markdown", "Output feature as a Markdown document instead of JSON").action(async (options) => {
10564
+ /**
10565
+ * Topological sort: parents before children. Features with no lineage come first.
10566
+ * Cycles are broken by key order (shouldn't happen in a valid workspace).
10567
+ */
10568
+ function topoSort(features) {
10569
+ const keyToFeature = new Map(features.map((f) => [f.feature.featureKey, f]));
10570
+ const visited = /* @__PURE__ */ new Set();
10571
+ const result = [];
10572
+ function visit(key) {
10573
+ if (visited.has(key)) return;
10574
+ visited.add(key);
10575
+ const f = keyToFeature.get(key);
10576
+ if (!f) return;
10577
+ const parent = f.feature.lineage?.parent;
10578
+ if (parent && keyToFeature.has(parent)) visit(parent);
10579
+ result.push(f);
10580
+ }
10581
+ for (const f of features) visit(f.feature.featureKey);
10582
+ return result;
10583
+ }
10584
+ /**
10585
+ * Render a compact ASCII tree of the parent→child hierarchy.
10586
+ */
10587
+ function renderLineageTree(features) {
10588
+ const childrenOf = /* @__PURE__ */ new Map();
10589
+ const roots = [];
10590
+ for (const f of features) {
10591
+ const parent = f.feature.lineage?.parent;
10592
+ if (parent) {
10593
+ const list = childrenOf.get(parent) ?? [];
10594
+ list.push(f.feature.featureKey);
10595
+ childrenOf.set(parent, list);
10596
+ } else roots.push(f.feature.featureKey);
10597
+ }
10598
+ const keyToTitle = new Map(features.map((f) => [f.feature.featureKey, f.feature.title]));
10599
+ const treeLines = [];
10600
+ function renderNode(key, prefix, isLast) {
10601
+ const connector = isLast ? "└── " : "├── ";
10602
+ treeLines.push(`${prefix}${connector}${key} — ${keyToTitle.get(key) ?? ""}`);
10603
+ const children = childrenOf.get(key) ?? [];
10604
+ const childPrefix = prefix + (isLast ? " " : "│ ");
10605
+ children.forEach((child, i) => renderNode(child, childPrefix, i === children.length - 1));
10606
+ }
10607
+ roots.forEach((root, i) => renderNode(root, "", i === roots.length - 1));
10608
+ return treeLines.join("\n");
10609
+ }
10610
+ /**
10611
+ * Build a reconstruction prompt from all feature.jsons under a directory.
10612
+ * The output is a single Markdown document a fresh AI can consume to
10613
+ * re-implement the system from its documented intent alone.
10614
+ */
10615
+ function buildReconstructionPrompt(features, projectName, promptDir) {
10616
+ const lines = [];
10617
+ lines.push(`# Reconstruction Spec — ${projectName}`);
10618
+ lines.push("");
10619
+ lines.push("> This document fully describes a software system through its feature documentation.", "> Your task is to implement this system from scratch.", "> Do not reproduce the original source — implement cleanly to satisfy each feature's", "> problem statement, decisions, and success criteria.", "> Features are listed in dependency order (parents before children).");
10620
+ lines.push("");
10621
+ const total = features.length;
10622
+ const frozen = features.filter((f) => f.feature.status === "frozen").length;
10623
+ const domains = [...new Set(features.map((f) => f.feature.domain).filter(Boolean))];
10624
+ lines.push(`**${total} features** · ${frozen} frozen · ${domains.length} domains: ${domains.join(", ")}`);
10625
+ lines.push("");
10626
+ const tree = renderLineageTree(features);
10627
+ if (tree) {
10628
+ lines.push("## Feature Tree");
10629
+ lines.push("");
10630
+ lines.push("```");
10631
+ lines.push(tree);
10632
+ lines.push("```");
10633
+ lines.push("");
10634
+ }
10635
+ const sorted = topoSort(features);
10636
+ const renderFeature = (f) => {
10637
+ const feat = f.feature;
10638
+ const parts = [];
10639
+ const featureDir = dirname(f.filePath);
10640
+ const relDir = featureDir === promptDir ? "." : featureDir.slice(promptDir.length).replace(/^[\\/]/, "").replace(/\\/g, "/");
10641
+ parts.push(`### ${feat.featureKey} — ${feat.title}`);
10642
+ parts.push("");
10643
+ parts.push(`**Status:** ${feat.status}`);
10644
+ parts.push(`**Path:** \`${relDir}/\``);
10645
+ if (feat.lineage?.parent) parts.push(`**Parent:** ${feat.lineage.parent}`);
10646
+ if (feat.lineage?.children?.length) parts.push(`**Children:** ${feat.lineage.children.join(", ")}`);
10647
+ parts.push("");
10648
+ parts.push(`**Problem:** ${feat.problem}`);
10649
+ parts.push("");
10650
+ if (feat.analysis) {
10651
+ parts.push("**Analysis:**");
10652
+ parts.push(feat.analysis);
10653
+ parts.push("");
10654
+ }
10655
+ if (feat.implementation) {
10656
+ parts.push("**Implementation:**");
10657
+ parts.push(feat.implementation);
10658
+ parts.push("");
10659
+ }
10660
+ if (feat.decisions?.length) {
10661
+ parts.push("**Decisions:**");
10662
+ for (const d of feat.decisions) {
10663
+ parts.push(`- **${d.decision}** — ${d.rationale}`);
10664
+ if (d.alternativesConsidered?.length) parts.push(` Alternatives considered: ${d.alternativesConsidered.join(", ")}`);
10665
+ }
10666
+ parts.push("");
10667
+ }
10668
+ if (feat.knownLimitations?.length) {
10669
+ parts.push("**Known Limitations:**");
10670
+ for (const lim of feat.knownLimitations) parts.push(`- ${lim}`);
10671
+ parts.push("");
10672
+ }
10673
+ if (feat.successCriteria) {
10674
+ parts.push(`**Success Criteria:** ${feat.successCriteria}`);
10675
+ parts.push("");
10676
+ }
10677
+ if (feat.tags?.length) {
10678
+ parts.push(`**Tags:** ${feat.tags.join(", ")}`);
10679
+ parts.push("");
10680
+ }
10681
+ return parts.join("\n");
10682
+ };
10683
+ lines.push("## Features");
10684
+ lines.push("");
10685
+ for (const f of sorted) {
10686
+ lines.push(renderFeature(f));
10687
+ lines.push("---");
10688
+ lines.push("");
10689
+ }
10690
+ lines.push("## Reconstruction Instructions");
10691
+ lines.push("");
10692
+ lines.push("Using only the feature specs above (no original source code):", "", "1. Identify the tech stack implied by the decisions and tags", "2. Implement each feature in the order listed above (parents are always listed before children)", "3. Place each feature's code in the directory indicated by its **Path** field", "4. For each feature, satisfy its Problem, Success Criteria, and honour its Decisions", "5. Respect Known Limitations — do not over-engineer around them unless specified", "6. The result should pass all Success Criteria when run");
10693
+ lines.push("");
10694
+ return lines.join("\n");
10695
+ }
10696
+ const exportCommand = new Command("export").description("Export feature.json as JSON, Markdown, or generate a static HTML wiki").option("--out <path>", "Output file or directory path").option("--html [dir]", "Scan <dir> (default: cwd) and emit a single self-contained HTML wiki file").option("--site <dir>", "Scan <dir> for feature.json files and generate a static HTML site (outputs index.html in --out dir)").option("--prompt [dir]", "Scan <dir> for all feature.json files and emit a single reconstruction prompt (default: cwd)").option("--markdown", "Output feature as a Markdown document instead of JSON").option("--tags <tags>", "Comma-separated tags to filter by in --site/--html mode — only features with at least one matching tag are exported (OR logic)").action(async (options) => {
10697
+ if (options.prompt !== void 0) {
10698
+ const promptDir = typeof options.prompt === "string" ? resolve(options.prompt) : resolve(process$1.cwd());
10699
+ let features;
10700
+ try {
10701
+ features = await scanFeatures(promptDir);
10702
+ } catch (err) {
10703
+ const message = err instanceof Error ? err.message : String(err);
10704
+ process$1.stderr.write(`Error scanning "${promptDir}": ${message}\n`);
10705
+ process$1.exit(1);
10706
+ }
10707
+ if (features.length === 0) {
10708
+ process$1.stdout.write(`No valid feature.json files found in "${promptDir}".\n`);
10709
+ process$1.exit(0);
10710
+ }
10711
+ const projectName = basename(promptDir);
10712
+ const prompt = buildReconstructionPrompt(features, projectName, promptDir);
10713
+ if (options.out) {
10714
+ const outPath = resolve(options.out);
10715
+ try {
10716
+ await writeFile(outPath, prompt, "utf-8");
10717
+ process$1.stdout.write(`✓ Reconstruction prompt (${features.length} features) → ${options.out}\n`);
10718
+ } catch (err) {
10719
+ const message = err instanceof Error ? err.message : String(err);
10720
+ process$1.stderr.write(`Error writing to "${options.out}": ${message}\n`);
10721
+ process$1.exit(1);
10722
+ }
10723
+ } else process$1.stdout.write(prompt);
10724
+ return;
10725
+ }
10726
+ if (options.html !== void 0) {
10727
+ const htmlDir = typeof options.html === "string" ? resolve(options.html) : resolve(process$1.cwd());
10728
+ let features;
10729
+ try {
10730
+ features = await scanFeatures(htmlDir);
10731
+ } catch (err) {
10732
+ const message = err instanceof Error ? err.message : String(err);
10733
+ process$1.stderr.write(`Error scanning "${htmlDir}": ${message}\n`);
10734
+ process$1.exit(1);
10735
+ }
10736
+ if (options.tags) {
10737
+ const tagsToMatch = options.tags.split(",").map((t) => t.trim()).filter(Boolean);
10738
+ features = features.filter(({ feature }) => tagsToMatch.some((tag) => feature.tags?.includes(tag)));
10739
+ }
10740
+ if (features.length === 0) {
10741
+ process$1.stdout.write(`No valid feature.json files found in "${htmlDir}".\n`);
10742
+ process$1.exit(0);
10743
+ }
10744
+ const projectName = basename(htmlDir);
10745
+ const html = generateHtmlWiki(features.map((f) => f.feature), projectName);
10746
+ const outFile = options.out ? resolve(options.out) : resolve(process$1.cwd(), "lac-wiki.html");
10747
+ try {
10748
+ await writeFile(outFile, html, "utf-8");
10749
+ process$1.stdout.write(`✓ HTML wiki (${features.length} features) → ${options.out ?? "lac-wiki.html"}\n`);
10750
+ } catch (err) {
10751
+ const message = err instanceof Error ? err.message : String(err);
10752
+ process$1.stderr.write(`Error writing "${outFile}": ${message}\n`);
10753
+ process$1.exit(1);
10754
+ }
10755
+ return;
10756
+ }
9362
10757
  if (options.site !== void 0) {
9363
10758
  const scanDir = resolve(options.site);
9364
10759
  const outDir = resolve(options.out ?? "./lac-site");
@@ -9370,6 +10765,10 @@ const exportCommand = new Command("export").description("Export feature.json as
9370
10765
  process$1.stderr.write(`Error scanning "${scanDir}": ${message}\n`);
9371
10766
  process$1.exit(1);
9372
10767
  }
10768
+ if (options.tags) {
10769
+ const tagsToMatch = options.tags.split(",").map((t) => t.trim()).filter(Boolean);
10770
+ features = features.filter(({ feature }) => tagsToMatch.some((tag) => feature.tags?.includes(tag)));
10771
+ }
9373
10772
  if (features.length === 0) {
9374
10773
  process$1.stdout.write(`No valid feature.json files found in "${scanDir}".\n`);
9375
10774
  process$1.exit(0);
@@ -9627,10 +11026,12 @@ function buildTree(key, byKey, childrenOf, visited = /* @__PURE__ */ new Set())
9627
11026
  const childKeys = childrenOf.get(key) ?? [];
9628
11027
  const children = [];
9629
11028
  for (const ck of childKeys) if (!visited.has(ck)) children.push(buildTree(ck, byKey, childrenOf, visited));
11029
+ children.sort((a, b) => (a.priority ?? 9999) - (b.priority ?? 9999));
9630
11030
  return {
9631
11031
  key,
9632
11032
  status: feature?.status ?? "unknown",
9633
11033
  title: feature?.title ?? "(unknown)",
11034
+ priority: feature?.priority,
9634
11035
  children
9635
11036
  };
9636
11037
  }
@@ -9688,7 +11089,15 @@ const lineageCommand = new Command("lineage").description("Show the lineage tree
9688
11089
 
9689
11090
  //#endregion
9690
11091
  //#region src/commands/lint.ts
9691
- function checkFeature(feature, filePath, requiredFields, threshold) {
11092
+ /** Intent-critical fields that should have a revision entry when non-empty */
11093
+ const INTENT_CRITICAL_FIELDS = [
11094
+ "problem",
11095
+ "analysis",
11096
+ "implementation",
11097
+ "decisions",
11098
+ "successCriteria"
11099
+ ];
11100
+ function checkFeature(feature, filePath, requiredFields, threshold, revisionWarnings = true) {
9692
11101
  const raw = feature;
9693
11102
  const completeness = computeCompleteness(raw);
9694
11103
  const missingRequired = requiredFields.filter((field) => {
@@ -9698,6 +11107,20 @@ function checkFeature(feature, filePath, requiredFields, threshold) {
9698
11107
  return typeof val === "string" && val.trim().length === 0;
9699
11108
  });
9700
11109
  const belowThreshold = threshold > 0 && completeness < threshold;
11110
+ const warnings = [];
11111
+ const hasRevisions = Array.isArray(raw.revisions) && raw.revisions.length > 0;
11112
+ if (revisionWarnings && !hasRevisions) {
11113
+ const filledCritical = INTENT_CRITICAL_FIELDS.filter((field) => {
11114
+ const val = raw[field];
11115
+ if (val === void 0 || val === null) return false;
11116
+ if (typeof val === "string") return val.trim().length > 0;
11117
+ if (Array.isArray(val)) return val.length > 0;
11118
+ return false;
11119
+ });
11120
+ if (filledCritical.length > 0) warnings.push(`no revisions recorded — consider adding a revision entry for: ${filledCritical.join(", ")}`);
11121
+ }
11122
+ if (raw.superseded_by && feature.status !== "deprecated") warnings.push(`superseded_by is set but status is "${feature.status}" — consider deprecating`);
11123
+ if (raw.merged_into && feature.status !== "deprecated") warnings.push(`merged_into is set but status is "${feature.status}" — consider deprecating`);
9701
11124
  return {
9702
11125
  featureKey: feature.featureKey,
9703
11126
  filePath,
@@ -9705,7 +11128,8 @@ function checkFeature(feature, filePath, requiredFields, threshold) {
9705
11128
  completeness,
9706
11129
  missingRequired,
9707
11130
  belowThreshold,
9708
- pass: missingRequired.length === 0 && !belowThreshold
11131
+ pass: missingRequired.length === 0 && !belowThreshold,
11132
+ warnings
9709
11133
  };
9710
11134
  }
9711
11135
  /** Default placeholder values for auto-fix of missing required fields */
@@ -9746,15 +11170,17 @@ async function fixFeature(filePath, missingFields) {
9746
11170
  await writeFile(filePath, JSON.stringify(validation.data, null, 2) + "\n", "utf-8");
9747
11171
  return fixed;
9748
11172
  }
9749
- const lintCommand = new Command("lint").description("Check feature.json files for completeness and required fields").argument("[dir]", "Directory to scan (default: current directory)").option("--require <fields>", "Comma-separated required fields (overrides lac.config.json)").option("--threshold <n>", "Minimum completeness % required (overrides lac.config.json)", parseInt).option("--quiet", "Only print failures, suppress passing results").option("--json", "Output results as JSON").option("--watch", "Re-run lint on every feature.json change").option("--fix", "Auto-insert default values for missing required fields").action(async (dir, options) => {
11173
+ const lintCommand = new Command("lint").description("Check feature.json files for completeness and required fields").argument("[dir]", "Directory to scan (default: current directory)").option("--require <fields>", "Comma-separated required fields (overrides lac.config.json)").option("--threshold <n>", "Minimum completeness % required (overrides lac.config.json)", parseInt).option("--quiet", "Only print failures, suppress passing results").option("--json", "Output results as JSON").option("--watch", "Re-run lint on every feature.json change").option("--fix", "Auto-insert default values for missing required fields").option("--include-archived", "Include features inside _archive/ directories").option("--no-revision-warnings", "Suppress \"no revisions recorded\" warnings (useful during migration)").option("--tags <tags>", "Comma-separated tags to filter by — only lint features with at least one matching tag (OR logic)").action(async (dir, options) => {
9750
11174
  const scanDir = resolve(dir ?? process$1.cwd());
9751
11175
  const config$2 = loadConfig(scanDir);
9752
11176
  const requiredFields = options.require ? options.require.split(",").map((f) => f.trim()).filter(Boolean) : config$2.requiredFields;
9753
11177
  const threshold = options.threshold !== void 0 ? options.threshold : config$2.ciThreshold;
11178
+ const scanOpts = { includeArchived: options.includeArchived ?? false };
11179
+ const revisionWarnings = options.revisionWarnings ?? true;
9754
11180
  async function runLint() {
9755
11181
  let scanned;
9756
11182
  try {
9757
- scanned = await scanFeatures(scanDir);
11183
+ scanned = await scanFeatures(scanDir, scanOpts);
9758
11184
  } catch (err) {
9759
11185
  const message = err instanceof Error ? err.message : String(err);
9760
11186
  process$1.stderr.write(`Error scanning "${scanDir}": ${message}\n`);
@@ -9764,7 +11190,33 @@ const lintCommand = new Command("lint").description("Check feature.json files fo
9764
11190
  process$1.stdout.write(`No feature.json files found in "${scanDir}".\n`);
9765
11191
  return 0;
9766
11192
  }
9767
- const results = scanned.filter(({ feature }) => config$2.lintStatuses.includes(feature.status)).map(({ feature, filePath }) => checkFeature(feature, filePath, requiredFields, threshold));
11193
+ let toCheck = scanned.filter(({ feature }) => config$2.lintStatuses.includes(feature.status));
11194
+ if (options.tags) {
11195
+ const tagsToMatch = options.tags.split(",").map((t) => t.trim()).filter(Boolean);
11196
+ toCheck = toCheck.filter(({ feature }) => tagsToMatch.some((tag) => feature.tags?.includes(tag)));
11197
+ }
11198
+ const results = toCheck.map(({ feature, filePath }) => checkFeature(feature, filePath, requiredFields, threshold, revisionWarnings));
11199
+ const featureByKey = new Map(scanned.map(({ feature }) => [feature.featureKey, feature]));
11200
+ for (const result of results) {
11201
+ const raw = featureByKey.get(result.featureKey);
11202
+ if (!raw) continue;
11203
+ if (raw.merged_into) {
11204
+ const target = featureByKey.get(String(raw.merged_into));
11205
+ if (target) {
11206
+ if (!(target.merged_from ?? []).includes(result.featureKey)) result.warnings.push(`merged_into "${raw.merged_into}" but that feature does not list this key in merged_from`);
11207
+ }
11208
+ }
11209
+ for (const sourceKey of raw.merged_from ?? []) {
11210
+ const source = featureByKey.get(sourceKey);
11211
+ if (source && source.merged_into !== result.featureKey) result.warnings.push(`merged_from includes "${sourceKey}" but that feature does not point merged_into this key`);
11212
+ }
11213
+ if (raw.superseded_by) {
11214
+ const successor = featureByKey.get(String(raw.superseded_by));
11215
+ if (successor) {
11216
+ if (!(successor.superseded_from ?? []).includes(result.featureKey)) result.warnings.push(`superseded_by "${raw.superseded_by}" but that feature does not list this key in superseded_from`);
11217
+ }
11218
+ }
11219
+ }
9768
11220
  if (options.fix) {
9769
11221
  const toFix = results.filter((r) => !r.pass && r.missingRequired.length > 0);
9770
11222
  let totalFixed = 0;
@@ -9785,12 +11237,12 @@ const lintCommand = new Command("lint").description("Check feature.json files fo
9785
11237
  }
9786
11238
  let rescanned;
9787
11239
  try {
9788
- rescanned = await scanFeatures(scanDir);
11240
+ rescanned = await scanFeatures(scanDir, scanOpts);
9789
11241
  } catch {
9790
11242
  process$1.stdout.write(`\n✓ Fixed ${totalFixed} field${totalFixed === 1 ? "" : "s"}. Could not re-validate — run "lac lint" to confirm.\n`);
9791
11243
  return 0;
9792
11244
  }
9793
- const stillFailing = rescanned.filter(({ feature }) => config$2.lintStatuses.includes(feature.status)).map(({ feature, filePath }) => checkFeature(feature, filePath, requiredFields, threshold)).filter((r) => !r.pass);
11245
+ const stillFailing = rescanned.filter(({ feature }) => config$2.lintStatuses.includes(feature.status)).map(({ feature, filePath }) => checkFeature(feature, filePath, requiredFields, threshold, revisionWarnings)).filter((r) => !r.pass);
9794
11246
  if (stillFailing.length === 0) {
9795
11247
  process$1.stdout.write(`\n✓ Fixed ${totalFixed} field${totalFixed === 1 ? "" : "s"} — all features now pass lint.\n`);
9796
11248
  return 0;
@@ -9801,11 +11253,13 @@ const lintCommand = new Command("lint").description("Check feature.json files fo
9801
11253
  }
9802
11254
  const failures = results.filter((r) => !r.pass);
9803
11255
  const passes = results.filter((r) => r.pass);
11256
+ const warnings = results.filter((r) => r.warnings.length > 0);
9804
11257
  if (options.json) {
9805
11258
  process$1.stdout.write(JSON.stringify({
9806
11259
  results,
9807
11260
  failures: failures.length,
9808
- passes: passes.length
11261
+ passes: passes.length,
11262
+ warningCount: warnings.length
9809
11263
  }, null, 2) + "\n");
9810
11264
  return failures.length > 0 ? 1 : 0;
9811
11265
  }
@@ -9818,7 +11272,11 @@ const lintCommand = new Command("lint").description("Check feature.json files fo
9818
11272
  for (const field of r.missingRequired) process$1.stdout.write(` missing required field: ${field}\n`);
9819
11273
  if (r.belowThreshold) process$1.stdout.write(` completeness ${r.completeness}% is below threshold ${threshold}%\n`);
9820
11274
  }
9821
- process$1.stdout.write(`\n${passes.length} passed, ${failures.length} failed ${results.length} features checked\n`);
11275
+ if (!options.quiet && warnings.length > 0) {
11276
+ process$1.stdout.write("\nWarnings:\n");
11277
+ for (const r of warnings) for (const w of r.warnings) process$1.stdout.write(` ⚠ ${col(r.featureKey, 18)} ${w}\n`);
11278
+ }
11279
+ process$1.stdout.write(`\n${passes.length} passed, ${failures.length} failed, ${warnings.length} warned — ${results.length} features checked\n`);
9822
11280
  if (failures.length > 0) {
9823
11281
  if (!options.quiet) {
9824
11282
  process$1.stdout.write(`\nFailing features:\n`);
@@ -10049,8 +11507,12 @@ const renameCommand = new Command("rename").description("Rename a featureKey —
10049
11507
 
10050
11508
  //#endregion
10051
11509
  //#region src/commands/search.ts
10052
- const searchCommand = new Command("search").description("Search features by keyword across key, title, problem, tags, and analysis").argument("<query>", "Search query (case-insensitive)").option("-d, --dir <path>", "Directory to scan (default: cwd)").option("--json", "Output results as JSON").option("--field <fields>", "Comma-separated fields to search (default: all)").action(async (query, options) => {
10053
- const features = await scanFeatures(options.dir ?? process$1.cwd());
11510
+ const searchCommand = new Command("search").description("Search features by keyword across key, title, problem, tags, and analysis").argument("<query>", "Search query (case-insensitive)").option("-d, --dir <path>", "Directory to scan (default: cwd)").option("--json", "Output results as JSON").option("--field <fields>", "Comma-separated fields to search (default: all)").option("--tags <tags>", "Comma-separated tags to filter by — only features with at least one matching tag are searched (OR logic)").action(async (query, options) => {
11511
+ let features = await scanFeatures(options.dir ?? process$1.cwd());
11512
+ if (options.tags) {
11513
+ const tagsToMatch = options.tags.split(",").map((t) => t.trim()).filter(Boolean);
11514
+ features = features.filter(({ feature }) => tagsToMatch.some((tag) => feature.tags?.includes(tag)));
11515
+ }
10054
11516
  const searchFields = options.field ? options.field.split(",").map((f) => f.trim()) : [
10055
11517
  "featureKey",
10056
11518
  "title",
@@ -10332,7 +11794,7 @@ const spawnCommand = new Command("spawn").description("Spawn a child feature fro
10332
11794
 
10333
11795
  //#endregion
10334
11796
  //#region src/commands/stat.ts
10335
- const statCommand = new Command("stat").description("Show workspace statistics: feature counts, status breakdown, completeness, top tags").option("-d, --dir <path>", "Directory to scan (default: cwd)").action(async (options) => {
11797
+ const statCommand = new Command("stat").description("Show workspace statistics: feature counts, status breakdown, completeness, top tags").option("-d, --dir <path>", "Directory to scan (default: cwd)").option("--tags <tags>", "Comma-separated tags to filter by — scope stats to features with at least one matching tag (OR logic)").option("--by-tag", "Group output by tag — show per-tag feature counts and status breakdown").action(async (options) => {
10336
11798
  const scanDir = options.dir ?? process$1.cwd();
10337
11799
  let features;
10338
11800
  try {
@@ -10342,6 +11804,10 @@ const statCommand = new Command("stat").description("Show workspace statistics:
10342
11804
  process$1.stderr.write(`Error scanning "${scanDir}": ${message}\n`);
10343
11805
  process$1.exit(1);
10344
11806
  }
11807
+ if (options.tags) {
11808
+ const tagsToMatch = options.tags.split(",").map((t) => t.trim()).filter(Boolean);
11809
+ features = features.filter(({ feature }) => tagsToMatch.some((tag) => feature.tags?.includes(tag)));
11810
+ }
10345
11811
  const total = features.length;
10346
11812
  if (total === 0) {
10347
11813
  process$1.stdout.write(`No features found in "${scanDir}".\n`);
@@ -10382,9 +11848,170 @@ const statCommand = new Command("stat").description("Show workspace statistics:
10382
11848
  lines.push("Top 5 tags:");
10383
11849
  for (const [tag, count] of topTags) lines.push(` ${tag.padEnd(20)}: ${count}`);
10384
11850
  }
11851
+ if (options.byTag) {
11852
+ lines.push("");
11853
+ lines.push("By tag:");
11854
+ const allTags = Array.from(tagCounts.keys()).sort();
11855
+ for (const tag of allTags) {
11856
+ const tagged = features.filter(({ feature }) => feature.tags?.includes(tag));
11857
+ const byStatus = {};
11858
+ for (const { feature } of tagged) byStatus[feature.status] = (byStatus[feature.status] ?? 0) + 1;
11859
+ const statusSummary = Object.entries(byStatus).map(([s, n]) => `${s}:${n}`).join(" ");
11860
+ lines.push(` ${tag.padEnd(22)} ${tagged.length.toString().padStart(3)} features (${statusSummary})`);
11861
+ }
11862
+ const untagged = features.filter(({ feature }) => !feature.tags || feature.tags.length === 0);
11863
+ if (untagged.length > 0) lines.push(` ${"(untagged)".padEnd(22)} ${untagged.length.toString().padStart(3)} features`);
11864
+ }
10385
11865
  process$1.stdout.write(lines.join("\n") + "\n");
10386
11866
  });
10387
11867
 
11868
+ //#endregion
11869
+ //#region src/commands/strip.ts
11870
+ const DEFAULT_KEEP = new Set([
11871
+ "feature.json",
11872
+ "package.json",
11873
+ "package-lock.json",
11874
+ "bun.lock",
11875
+ "yarn.lock",
11876
+ "pnpm-lock.yaml",
11877
+ "tsconfig.json",
11878
+ "tsconfig.base.json",
11879
+ "vite.config.ts",
11880
+ "vite.config.js",
11881
+ "vitest.config.ts",
11882
+ "vitest.config.js",
11883
+ "next.config.ts",
11884
+ "next.config.js",
11885
+ "tailwind.config.ts",
11886
+ "tailwind.config.js",
11887
+ "postcss.config.js",
11888
+ "postcss.config.ts",
11889
+ ".gitignore",
11890
+ ".gitattributes",
11891
+ "README.md",
11892
+ "LICENSE",
11893
+ "Makefile",
11894
+ ".env.example",
11895
+ "turbo.json"
11896
+ ]);
11897
+ const SKIP_DIRS = new Set([
11898
+ "node_modules",
11899
+ ".git",
11900
+ "dist",
11901
+ "build",
11902
+ "out",
11903
+ ".turbo",
11904
+ ".next",
11905
+ ".nuxt",
11906
+ "__pycache__",
11907
+ ".venv",
11908
+ "venv",
11909
+ "target",
11910
+ "vendor",
11911
+ "coverage"
11912
+ ]);
11913
+ function collectDeletable(dir, keepNames) {
11914
+ const deletable = [];
11915
+ function walk(current) {
11916
+ let entries;
11917
+ try {
11918
+ entries = fs.readdirSync(current, { withFileTypes: true });
11919
+ } catch {
11920
+ return;
11921
+ }
11922
+ for (const entry of entries) {
11923
+ const fullPath = path.join(current, entry.name);
11924
+ if (entry.isDirectory()) {
11925
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
11926
+ walk(fullPath);
11927
+ } else if (entry.isFile()) {
11928
+ if (!keepNames.has(entry.name)) deletable.push({
11929
+ absolutePath: fullPath,
11930
+ relativePath: path.relative(dir, fullPath).replace(/\\/g, "/")
11931
+ });
11932
+ }
11933
+ }
11934
+ }
11935
+ walk(dir);
11936
+ return deletable;
11937
+ }
11938
+ function readLine(prompt) {
11939
+ return new Promise((resolve$1) => {
11940
+ const rl = readline.createInterface({
11941
+ input: process$1.stdin,
11942
+ output: process$1.stdout
11943
+ });
11944
+ rl.question(prompt, (answer) => {
11945
+ rl.close();
11946
+ resolve$1(answer.trim());
11947
+ });
11948
+ });
11949
+ }
11950
+ const stripCommand = new Command("strip").description("Export a reconstruction prompt then delete all non-feature source files.\nUseful for reducing a documented repo to its spec only.\nRuns export --prompt first, shows a dry-run, then asks for confirmation.").argument("[path]", "Root directory to strip (default: current directory)").option("--out <file>", "Write the reconstruction prompt to <file> before deleting").option("--keep <names>", "Comma-separated extra file names to preserve (added to built-in keep-list)", "").option("--dry-run", "Show what would be deleted without removing anything").option("--yes", "Skip the confirmation prompt").action(async (targetArg, opts) => {
11951
+ const targetDir = path.resolve(targetArg ?? process$1.cwd());
11952
+ if (!fs.existsSync(targetDir)) {
11953
+ process$1.stderr.write(`Error: path "${targetDir}" does not exist.\n`);
11954
+ process$1.exit(1);
11955
+ }
11956
+ process$1.stdout.write(`\nScanning "${targetDir}" for feature.json files...\n`);
11957
+ let features;
11958
+ try {
11959
+ features = await scanFeatures(targetDir);
11960
+ } catch (err) {
11961
+ const message = err instanceof Error ? err.message : String(err);
11962
+ process$1.stderr.write(`Error scanning features: ${message}\n`);
11963
+ process$1.exit(1);
11964
+ }
11965
+ if (features.length === 0) {
11966
+ process$1.stderr.write(`Error: no valid feature.json files found in "${targetDir}". Run "lac extract-all" first.\n`);
11967
+ process$1.exit(1);
11968
+ }
11969
+ process$1.stdout.write(` Found ${features.length} feature${features.length === 1 ? "" : "s"}.\n`);
11970
+ if (opts.out) {
11971
+ const outPath = path.resolve(opts.out);
11972
+ const prompt = buildReconstructionPrompt(features, basename(targetDir), targetDir);
11973
+ try {
11974
+ await writeFile(outPath, prompt, "utf-8");
11975
+ process$1.stdout.write(` ✓ Reconstruction prompt (${features.length} features) → ${opts.out}\n`);
11976
+ } catch (err) {
11977
+ const message = err instanceof Error ? err.message : String(err);
11978
+ process$1.stderr.write(`Warning: could not write reconstruction prompt to "${opts.out}": ${message}\n`);
11979
+ }
11980
+ } else process$1.stdout.write(`\nTip: run "lac export --prompt ${targetArg ?? "."} --out spec.md" to save the reconstruction prompt before stripping.\n`);
11981
+ const extraKeep = (opts.keep ?? "").split(",").map((s) => s.trim()).filter(Boolean);
11982
+ const deletable = collectDeletable(targetDir, new Set([...DEFAULT_KEEP, ...extraKeep]));
11983
+ if (deletable.length === 0) {
11984
+ process$1.stdout.write("\nNothing to delete — all source files are already in the keep-list.\n");
11985
+ return;
11986
+ }
11987
+ process$1.stdout.write(`\nThe following ${deletable.length} file${deletable.length === 1 ? "" : "s"} would be deleted:\n\n`);
11988
+ for (const f of deletable) process$1.stdout.write(` - ${f.relativePath}\n`);
11989
+ if (opts.dryRun) {
11990
+ process$1.stdout.write("\n[dry-run] No files deleted.\n");
11991
+ return;
11992
+ }
11993
+ if (!opts.yes) {
11994
+ if ((await readLine(`\nDelete ${deletable.length} file${deletable.length === 1 ? "" : "s"}? [y/N]: `)).toLowerCase() !== "y") {
11995
+ process$1.stdout.write("Aborted.\n");
11996
+ return;
11997
+ }
11998
+ }
11999
+ let deleted = 0;
12000
+ let failed = 0;
12001
+ for (const f of deletable) try {
12002
+ fs.unlinkSync(f.absolutePath);
12003
+ deleted++;
12004
+ } catch (err) {
12005
+ const message = err instanceof Error ? err.message : String(err);
12006
+ process$1.stderr.write(` Warning: could not delete "${f.relativePath}": ${message}\n`);
12007
+ failed++;
12008
+ }
12009
+ process$1.stdout.write(`\n✓ Deleted ${deleted} file${deleted === 1 ? "" : "s"}`);
12010
+ if (failed > 0) process$1.stdout.write(` (${failed} failed)`);
12011
+ process$1.stdout.write("\n");
12012
+ if (!opts.out) process$1.stdout.write("\nNext: run \"lac export --prompt . --out spec.md\" to generate the reconstruction prompt from the remaining feature.json files.\n");
12013
+ });
12014
+
10388
12015
  //#endregion
10389
12016
  //#region src/commands/tag.ts
10390
12017
  const tagCommand = new Command("tag").description("Add or remove tags on a feature").argument("<key>", "featureKey to tag (e.g. feat-2026-001)").argument("<tags>", "Comma-separated tags to add (prefix with - to remove, e.g. \"auth,-legacy,api\")").option("-d, --dir <path>", "Directory to scan for features (default: cwd)").action(async (key, tags, options) => {
@@ -10498,7 +12125,13 @@ program.addCommand(diffCommand);
10498
12125
  program.addCommand(renameCommand);
10499
12126
  program.addCommand(importCommand);
10500
12127
  program.addCommand(fillCommand);
12128
+ program.addCommand(extractAllCommand);
10501
12129
  program.addCommand(genCommand);
12130
+ program.addCommand(logCommand);
12131
+ program.addCommand(mergeCommand);
12132
+ program.addCommand(revisionsCommand);
12133
+ program.addCommand(supersedeCommand);
12134
+ program.addCommand(stripCommand);
10502
12135
  program.parse();
10503
12136
 
10504
12137
  //#endregion