@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/README.md +26 -2
- package/bin/lac-lsp.js +1 -1
- package/dist/index.mjs +2351 -718
- package/dist/index.mjs.map +1 -1
- package/dist/lsp.mjs +35 -1
- package/dist/mcp.mjs +2006 -440
- package/package.json +1 -1
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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$
|
|
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
|
-
|
|
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
|
|
7682
|
-
sourceFiles
|
|
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
|
|
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
|
|
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
|
|
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
|
-
...
|
|
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
|
|
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
|
-
|
|
8103
|
-
process$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/
|
|
8168
|
-
const
|
|
8169
|
-
|
|
8170
|
-
|
|
8171
|
-
|
|
8172
|
-
|
|
8173
|
-
|
|
8174
|
-
|
|
8175
|
-
|
|
8176
|
-
|
|
8177
|
-
|
|
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
|
-
|
|
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(`
|
|
8190
|
-
|
|
9020
|
+
process$1.stderr.write(`No feature.json found at "${featurePath}"\n`);
|
|
9021
|
+
process$1.exit(1);
|
|
8191
9022
|
}
|
|
8192
|
-
|
|
8193
|
-
|
|
8194
|
-
|
|
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
|
-
"
|
|
8199
|
-
"
|
|
8200
|
-
"annotations"
|
|
9133
|
+
"decisions",
|
|
9134
|
+
"successCriteria"
|
|
8201
9135
|
];
|
|
8202
|
-
function
|
|
8203
|
-
|
|
8204
|
-
const
|
|
8205
|
-
|
|
8206
|
-
|
|
8207
|
-
|
|
8208
|
-
|
|
8209
|
-
|
|
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/
|
|
8542
|
-
|
|
8543
|
-
|
|
8544
|
-
|
|
8545
|
-
|
|
8546
|
-
|
|
8547
|
-
|
|
8548
|
-
|
|
8549
|
-
|
|
8550
|
-
|
|
8551
|
-
|
|
8552
|
-
|
|
8553
|
-
|
|
8554
|
-
|
|
8555
|
-
|
|
8556
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
-
--
|
|
8656
|
-
--
|
|
8657
|
-
--
|
|
8658
|
-
--
|
|
8659
|
-
--
|
|
8660
|
-
--
|
|
8661
|
-
--
|
|
8662
|
-
--
|
|
8663
|
-
--
|
|
8664
|
-
--
|
|
8665
|
-
--
|
|
8666
|
-
|
|
8667
|
-
|
|
8668
|
-
|
|
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
|
-
|
|
8685
|
-
|
|
8686
|
-
|
|
9619
|
+
--status-active: #4aad72;
|
|
9620
|
+
--status-draft: #c4a255;
|
|
9621
|
+
--status-frozen: #5b82cc;
|
|
9622
|
+
--status-deprecated: #cc5b5b;
|
|
8687
9623
|
|
|
8688
|
-
|
|
8689
|
-
|
|
8690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8703
|
-
max-width: 800px;
|
|
8704
|
-
margin: 0 auto;
|
|
8705
|
-
padding: 2rem 1rem;
|
|
8706
|
-
}
|
|
9633
|
+
/* ── Shell ──────────────────────────────────────────────── */
|
|
8707
9634
|
|
|
8708
|
-
|
|
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
|
-
|
|
8716
|
-
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
|
|
8720
|
-
|
|
8721
|
-
padding
|
|
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
|
-
|
|
8725
|
-
|
|
8726
|
-
|
|
8727
|
-
}
|
|
9652
|
+
.body-row { display: flex; flex: 1; min-height: 0; }
|
|
9653
|
+
|
|
9654
|
+
/* ── Sidebar ────────────────────────────────────────────── */
|
|
8728
9655
|
|
|
8729
|
-
|
|
8730
|
-
|
|
8731
|
-
|
|
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
|
-
|
|
8735
|
-
|
|
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
|
-
|
|
9695
|
+
/* Domain group */
|
|
9696
|
+
.nav-group { margin-bottom: 2px; }
|
|
9697
|
+
|
|
9698
|
+
.nav-domain {
|
|
8739
9699
|
display: flex;
|
|
8740
9700
|
align-items: center;
|
|
8741
|
-
gap:
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
font-size:
|
|
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
|
-
.
|
|
8748
|
-
|
|
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
|
-
/*
|
|
8755
|
-
.
|
|
8756
|
-
display:
|
|
8757
|
-
|
|
8758
|
-
|
|
8759
|
-
|
|
8760
|
-
|
|
8761
|
-
|
|
8762
|
-
|
|
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
|
-
.
|
|
8766
|
-
|
|
8767
|
-
|
|
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
|
-
.
|
|
8772
|
-
|
|
8773
|
-
|
|
8774
|
-
border:
|
|
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
|
-
.
|
|
8778
|
-
|
|
8779
|
-
|
|
8780
|
-
|
|
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
|
-
.
|
|
8784
|
-
|
|
8785
|
-
|
|
8786
|
-
|
|
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
|
-
/*
|
|
8790
|
-
.search-wrapper {
|
|
8791
|
-
margin-bottom: 1.5rem;
|
|
8792
|
-
}
|
|
9778
|
+
/* ── Content ────────────────────────────────────────────── */
|
|
8793
9779
|
|
|
8794
|
-
.
|
|
8795
|
-
|
|
8796
|
-
|
|
8797
|
-
|
|
8798
|
-
|
|
8799
|
-
|
|
8800
|
-
|
|
8801
|
-
|
|
8802
|
-
|
|
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
|
-
|
|
8808
|
-
|
|
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
|
-
|
|
8812
|
-
|
|
8813
|
-
|
|
8814
|
-
|
|
8815
|
-
|
|
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
|
-
.
|
|
8819
|
-
|
|
8820
|
-
|
|
8821
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
|
|
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
|
-
|
|
8836
|
-
|
|
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-
|
|
8840
|
-
|
|
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
|
-
|
|
8844
|
-
|
|
8845
|
-
|
|
9882
|
+
.feature-key {
|
|
9883
|
+
font-family: var(--mono);
|
|
9884
|
+
font-size: 11px;
|
|
9885
|
+
color: var(--text-soft);
|
|
8846
9886
|
}
|
|
8847
9887
|
|
|
8848
|
-
|
|
8849
|
-
|
|
8850
|
-
|
|
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
|
-
.
|
|
8854
|
-
|
|
8855
|
-
|
|
8856
|
-
|
|
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
|
-
|
|
8862
|
-
|
|
8863
|
-
|
|
8864
|
-
|
|
8865
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8871
|
-
|
|
8872
|
-
margin-bottom: 1rem;
|
|
9932
|
+
top: -1px;
|
|
9933
|
+
overflow: hidden;
|
|
8873
9934
|
}
|
|
8874
|
-
|
|
8875
|
-
|
|
8876
|
-
|
|
9935
|
+
.completeness-fill {
|
|
9936
|
+
height: 100%;
|
|
9937
|
+
border-radius: 2px;
|
|
9938
|
+
background: var(--accent);
|
|
8877
9939
|
}
|
|
8878
9940
|
|
|
8879
|
-
|
|
8880
|
-
|
|
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
|
-
.
|
|
8891
|
-
|
|
8892
|
-
|
|
8893
|
-
|
|
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
|
-
|
|
8897
|
-
font-
|
|
8898
|
-
|
|
9959
|
+
.section-count {
|
|
9960
|
+
font-family: var(--mono);
|
|
9961
|
+
font-size: 10px;
|
|
9962
|
+
color: var(--border);
|
|
8899
9963
|
}
|
|
8900
9964
|
|
|
8901
|
-
.
|
|
8902
|
-
|
|
8903
|
-
|
|
8904
|
-
|
|
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
|
-
|
|
8908
|
-
|
|
8909
|
-
color: var(--color-text-muted);
|
|
8910
|
-
}
|
|
9980
|
+
/* Decision cards */
|
|
9981
|
+
.decisions-list { display: flex; flex-direction: column; gap: 12px; }
|
|
8911
9982
|
|
|
8912
|
-
.
|
|
8913
|
-
|
|
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
|
-
|
|
8917
|
-
|
|
8918
|
-
|
|
8919
|
-
|
|
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
|
-
|
|
8924
|
-
|
|
8925
|
-
|
|
10003
|
+
.decision-meta {
|
|
10004
|
+
display: flex;
|
|
10005
|
+
gap: 12px;
|
|
10006
|
+
flex-wrap: wrap;
|
|
8926
10007
|
}
|
|
8927
|
-
|
|
8928
|
-
|
|
8929
|
-
|
|
8930
|
-
color: var(--
|
|
10008
|
+
.decision-date {
|
|
10009
|
+
font-family: var(--mono);
|
|
10010
|
+
font-size: 10px;
|
|
10011
|
+
color: var(--text-soft);
|
|
8931
10012
|
}
|
|
8932
|
-
|
|
8933
|
-
|
|
8934
|
-
|
|
8935
|
-
|
|
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
|
-
|
|
8942
|
-
|
|
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
|
-
|
|
8946
|
-
|
|
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
|
-
/*
|
|
8950
|
-
.
|
|
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:
|
|
8954
|
-
|
|
8955
|
-
|
|
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
|
-
/*
|
|
8959
|
-
.
|
|
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
|
-
|
|
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
|
-
|
|
8966
|
-
|
|
8967
|
-
|
|
8968
|
-
|
|
8969
|
-
|
|
8970
|
-
|
|
10098
|
+
<script>
|
|
10099
|
+
const FEATURES = ${dataJson};
|
|
10100
|
+
|
|
10101
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
10102
|
+
|
|
10103
|
+
function esc(s) {
|
|
10104
|
+
return String(s)
|
|
10105
|
+
.replace(/&/g, '&')
|
|
10106
|
+
.replace(/</g, '<')
|
|
10107
|
+
.replace(/>/g, '>')
|
|
10108
|
+
.replace(/"/g, '"');
|
|
8971
10109
|
}
|
|
8972
10110
|
|
|
8973
|
-
|
|
8974
|
-
|
|
8975
|
-
|
|
8976
|
-
|
|
8977
|
-
|
|
8978
|
-
|
|
8979
|
-
|
|
8980
|
-
|
|
8981
|
-
|
|
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
|
-
|
|
8985
|
-
|
|
8986
|
-
|
|
8987
|
-
|
|
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
|
-
|
|
8994
|
-
|
|
8995
|
-
|
|
8996
|
-
|
|
8997
|
-
|
|
8998
|
-
|
|
8999
|
-
|
|
9000
|
-
|
|
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
|
-
|
|
9004
|
-
|
|
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
|
-
|
|
9010
|
-
|
|
9011
|
-
|
|
9012
|
-
|
|
9013
|
-
|
|
9014
|
-
|
|
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
|
-
|
|
9018
|
-
|
|
9019
|
-
|
|
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
|
-
|
|
9026
|
-
|
|
9027
|
-
|
|
9028
|
-
.
|
|
9029
|
-
|
|
9030
|
-
|
|
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
|
-
|
|
9034
|
-
|
|
9035
|
-
|
|
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
|
-
|
|
9039
|
-
|
|
9040
|
-
|
|
9041
|
-
|
|
9042
|
-
|
|
9043
|
-
|
|
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
|
-
|
|
9047
|
-
|
|
9048
|
-
|
|
9049
|
-
|
|
9050
|
-
|
|
9051
|
-
|
|
9052
|
-
|
|
9053
|
-
|
|
9054
|
-
|
|
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
|
-
|
|
9058
|
-
|
|
9059
|
-
|
|
9060
|
-
|
|
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
|
-
|
|
9064
|
-
|
|
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
|
-
|
|
9068
|
-
|
|
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
|
-
|
|
9072
|
-
|
|
9073
|
-
|
|
9074
|
-
|
|
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
|
-
|
|
9079
|
-
|
|
9080
|
-
|
|
9081
|
-
|
|
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 & 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">← 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
|
-
|
|
9179
|
-
|
|
9180
|
-
|
|
9181
|
-
|
|
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
|
-
|
|
9237
|
-
|
|
9238
|
-
|
|
9239
|
-
|
|
9240
|
-
|
|
9241
|
-
|
|
9242
|
-
|
|
9243
|
-
|
|
9244
|
-
|
|
9245
|
-
|
|
9246
|
-
|
|
9247
|
-
|
|
9248
|
-
|
|
9249
|
-
|
|
9250
|
-
|
|
9251
|
-
|
|
9252
|
-
|
|
9253
|
-
|
|
9254
|
-
|
|
9255
|
-
|
|
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
|
-
|
|
9277
|
-
const
|
|
9278
|
-
await writeFile(join(outDir, "index.html"),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|