@fenglimg/fabric-cli 2.0.0-rc.15 → 2.0.0-rc.22

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.
@@ -0,0 +1,225 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ resolveClients
4
+ } from "./chunk-MF3OTILQ.js";
5
+ import {
6
+ t
7
+ } from "./chunk-6ICJICVU.js";
8
+
9
+ // src/commands/config.ts
10
+ import { existsSync, statSync } from "fs";
11
+ import { readFile } from "fs/promises";
12
+ import { join, resolve } from "path";
13
+ import { fileURLToPath } from "url";
14
+ import { cancel, intro, isCancel, log, outro, select, text } from "@clack/prompts";
15
+ import { getPanelFields } from "@fenglimg/fabric-shared";
16
+ import { atomicWriteJson } from "@fenglimg/fabric-shared/node/atomic-write";
17
+ import { defineCommand } from "citty";
18
+ async function loadFabricConfig(workspaceRoot) {
19
+ const configPath = resolve(workspaceRoot, "fabric.config.json");
20
+ if (!existsSync(configPath)) {
21
+ return {};
22
+ }
23
+ const parsed = JSON.parse(await readFile(configPath, "utf8"));
24
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
25
+ throw new Error(t("cli.config.errors.expected-object", { path: configPath }));
26
+ }
27
+ return parsed;
28
+ }
29
+ function resolveServerPath(override) {
30
+ if (override) return override;
31
+ if (process.env.FAB_SERVER_PATH) return resolve(process.env.FAB_SERVER_PATH);
32
+ return fileURLToPath(import.meta.resolve("@fenglimg/fabric-server"));
33
+ }
34
+ var PANEL_CONFIG_RELATIVE_PATH = [".fabric", "fabric-config.json"];
35
+ var EXIT_CHOICE = "__exit__";
36
+ var configCmd = defineCommand({
37
+ meta: {
38
+ name: "config",
39
+ description: t("cli.config.description")
40
+ },
41
+ args: {
42
+ target: {
43
+ type: "string",
44
+ description: t("cli.config.args.target.description"),
45
+ valueHint: "path"
46
+ }
47
+ },
48
+ async run({ args }) {
49
+ const workspaceRoot = resolve(args.target ?? process.cwd());
50
+ const configPath = join(workspaceRoot, ...PANEL_CONFIG_RELATIVE_PATH);
51
+ const fabricDir = join(workspaceRoot, ".fabric");
52
+ const fabricDirOk = existsSync(fabricDir) && statSync(fabricDir).isDirectory();
53
+ const configOk = fabricDirOk && existsSync(configPath);
54
+ if (!configOk) {
55
+ console.error(t("cli.config.errors.uninit-workspace.message"));
56
+ process.exitCode = 1;
57
+ return;
58
+ }
59
+ if (!isInteractiveConfig()) {
60
+ console.log(t("cli.config.intro"));
61
+ console.log(t("cli.config.non-tty-notice"));
62
+ return;
63
+ }
64
+ intro(t("cli.config.intro"));
65
+ let edited = false;
66
+ while (true) {
67
+ const current = await readPanelConfig(configPath);
68
+ const fields = getPanelFields();
69
+ const fieldChoice = await select({
70
+ message: t("cli.config.menu.field-select"),
71
+ options: [
72
+ ...fields.map((field2) => ({
73
+ value: field2.key,
74
+ label: formatFieldMenuLabel(field2, current)
75
+ })),
76
+ { value: EXIT_CHOICE, label: t("cli.config.menu.exit") }
77
+ ]
78
+ });
79
+ if (isCancel(fieldChoice)) {
80
+ cancel(t("cli.config.cancel"));
81
+ return;
82
+ }
83
+ if (fieldChoice === EXIT_CHOICE) {
84
+ outro(edited ? t("cli.config.outro") : t("cli.config.outro-no-changes"));
85
+ return;
86
+ }
87
+ const field = fields.find((f) => f.key === fieldChoice);
88
+ if (!field) {
89
+ log.warn(t("cli.config.errors.unknown-field"));
90
+ continue;
91
+ }
92
+ const newValue = await promptFieldValue(field, current);
93
+ if (newValue === CANCELLED) {
94
+ cancel(t("cli.config.cancel"));
95
+ return;
96
+ }
97
+ if (newValue === SKIPPED) {
98
+ continue;
99
+ }
100
+ try {
101
+ const refreshed = await readPanelConfig(configPath);
102
+ const merged = { ...refreshed, [field.key]: newValue };
103
+ await atomicWriteJson(configPath, merged);
104
+ edited = true;
105
+ log.success(
106
+ t("cli.config.write.success", {
107
+ key: field.key,
108
+ value: field.format_for_display(newValue)
109
+ })
110
+ );
111
+ } catch (err) {
112
+ const message = err instanceof Error ? err.message : String(err);
113
+ log.error(t("cli.config.write.failure", { message }));
114
+ }
115
+ }
116
+ }
117
+ });
118
+ var config_default = configCmd;
119
+ var CANCELLED = /* @__PURE__ */ Symbol("config-cancelled");
120
+ var SKIPPED = /* @__PURE__ */ Symbol("config-skipped");
121
+ async function promptFieldValue(field, current) {
122
+ const currentValue = current[field.key];
123
+ const currentDisplay = field.format_for_display(currentValue);
124
+ if (field.widget === "select") {
125
+ const enumValues = field.enum_values ?? [];
126
+ if (enumValues.length === 0) {
127
+ log.warn(t("cli.config.errors.no-enum-options"));
128
+ return SKIPPED;
129
+ }
130
+ const initialValue = enumValues.includes(String(currentValue)) ? String(currentValue) : enumValues.includes(String(field.default)) ? String(field.default) : enumValues[0];
131
+ const picked = await select({
132
+ message: t("cli.config.prompt.select", {
133
+ key: field.key,
134
+ current: currentDisplay
135
+ }),
136
+ options: enumValues.map((value) => ({ value, label: value })),
137
+ initialValue
138
+ });
139
+ if (isCancel(picked)) {
140
+ return CANCELLED;
141
+ }
142
+ const result = field.validate(String(picked));
143
+ if (!result.ok) {
144
+ log.error(result.error);
145
+ return SKIPPED;
146
+ }
147
+ return result.value;
148
+ }
149
+ const entered = await text({
150
+ message: t("cli.config.prompt.text", {
151
+ key: field.key,
152
+ current: currentDisplay
153
+ }),
154
+ placeholder: currentDisplay,
155
+ initialValue: currentDisplay,
156
+ validate(raw) {
157
+ const result = field.validate(raw ?? "");
158
+ return result.ok ? void 0 : result.error;
159
+ }
160
+ });
161
+ if (isCancel(entered)) {
162
+ return CANCELLED;
163
+ }
164
+ const finalResult = field.validate(String(entered));
165
+ if (!finalResult.ok) {
166
+ log.error(finalResult.error);
167
+ return SKIPPED;
168
+ }
169
+ return finalResult.value;
170
+ }
171
+ function formatFieldMenuLabel(field, current) {
172
+ const key = field.key;
173
+ const rawValue = current[key];
174
+ const display = field.format_for_display(rawValue);
175
+ const isDefault = rawValue === void 0 || rawValue === null;
176
+ const labelText = t(field.label_i18n_key);
177
+ const valueLabel = isDefault ? `${display} ${t("cli.config.value.default-marker")}` : display;
178
+ return `[${field.group}] ${key} (${labelText}) \u2014 ${t("cli.config.value.current", { value: valueLabel })}`;
179
+ }
180
+ async function readPanelConfig(configPath) {
181
+ const raw = await readFile(configPath, "utf8");
182
+ const parsed = JSON.parse(raw);
183
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
184
+ throw new Error(t("cli.config.errors.expected-object", { path: configPath }));
185
+ }
186
+ return parsed;
187
+ }
188
+ function isInteractiveConfig() {
189
+ return Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY) && Boolean(process.stderr.isTTY);
190
+ }
191
+ async function installMcpClients(target, options = {}) {
192
+ const workspaceRoot = resolve(target);
193
+ const fabricConfig = await loadFabricConfig(workspaceRoot);
194
+ const selectedClients = options.clients === void 0 ? null : new Set(options.clients);
195
+ const serverPath = resolveServerPath(options.localServerPath);
196
+ const writers = resolveClients(workspaceRoot, fabricConfig, { claudeMcpScope: options.claudeMcpScope }).filter(
197
+ (writer) => selectedClients === null ? true : selectedClients.has(writer.clientKind)
198
+ );
199
+ const installed = [];
200
+ const skipped = [];
201
+ const details = [];
202
+ for (const writer of writers) {
203
+ const configPath = await writer.detect(workspaceRoot);
204
+ if (configPath === null) {
205
+ skipped.push(writer.clientKind);
206
+ details.push({ client: writer.clientKind, path: null, action: "skipped" });
207
+ continue;
208
+ }
209
+ if (options.dryRun) {
210
+ skipped.push(writer.clientKind);
211
+ details.push({ client: writer.clientKind, path: configPath, action: "dry-run" });
212
+ continue;
213
+ }
214
+ await writer.write(serverPath, workspaceRoot);
215
+ installed.push(writer.clientKind);
216
+ details.push({ client: writer.clientKind, path: configPath, action: "wrote" });
217
+ }
218
+ return { installed, skipped, details };
219
+ }
220
+
221
+ export {
222
+ configCmd,
223
+ config_default,
224
+ installMcpClients
225
+ };
@@ -479,7 +479,6 @@ function detectClientSupports(workspaceRoot, fabricConfig = {}) {
479
479
  clientKind: "ClaudeCodeCLI",
480
480
  label: "Claude Code CLI",
481
481
  detected: claudeDetected || hasExplicitPath(clientPaths, "claudeCodeCLI"),
482
- bootstrapTargetPath: ".fabric/bootstrap/README.md",
483
482
  configPath: "project .claude/settings.json",
484
483
  capabilities: {
485
484
  bootstrap: true,
@@ -496,7 +495,6 @@ function detectClientSupports(workspaceRoot, fabricConfig = {}) {
496
495
  clientKind: "ClaudeCodeDesktop",
497
496
  label: "Claude Code Desktop",
498
497
  detected: claudeDesktopDetected || hasExplicitPath(clientPaths, "claudeCodeDesktop"),
499
- bootstrapTargetPath: ".fabric/bootstrap/README.md",
500
498
  configPath: "desktop Claude config",
501
499
  capabilities: {
502
500
  bootstrap: true,
@@ -509,7 +507,6 @@ function detectClientSupports(workspaceRoot, fabricConfig = {}) {
509
507
  clientKind: "Cursor",
510
508
  label: "Cursor",
511
509
  detected: cursorDetected || hasExplicitPath(clientPaths, "cursor"),
512
- bootstrapTargetPath: ".fabric/bootstrap/README.md",
513
510
  configPath: ".cursor/mcp.json",
514
511
  capabilities: {
515
512
  bootstrap: true,
@@ -522,7 +519,6 @@ function detectClientSupports(workspaceRoot, fabricConfig = {}) {
522
519
  clientKind: "CodexCLI",
523
520
  label: "Codex CLI",
524
521
  detected: codexDetected || hasExplicitPath(clientPaths, "codexCLI"),
525
- bootstrapTargetPath: ".fabric/bootstrap/README.md",
526
522
  configPath: "~/.codex/config.toml",
527
523
  capabilities: {
528
524
  bootstrap: true,
@@ -4,12 +4,12 @@ import {
4
4
  } from "./chunk-6ICJICVU.js";
5
5
  import {
6
6
  readFabricConfig
7
- } from "./chunk-OBQU6NHO.js";
7
+ } from "./chunk-ZSESMG6L.js";
8
8
 
9
9
  // src/commands/scan.ts
10
10
  import { createHash } from "crypto";
11
11
  import { existsSync, readdirSync, readFileSync, statSync } from "fs";
12
- import { mkdir, readFile } from "fs/promises";
12
+ import { mkdir, readFile, unlink } from "fs/promises";
13
13
  import { dirname, isAbsolute, join, resolve } from "path";
14
14
  import {
15
15
  KnowledgeIdAllocator,
@@ -25,6 +25,30 @@ var SCAN_STATE_FILE = ".scan-state.json";
25
25
  var FORENSIC_FILE = ".fabric/forensic.json";
26
26
  var AGENTS_META_FILE = ".fabric/agents.meta.json";
27
27
  var LAYER_REASON = "project artifact (deterministic init scan)";
28
+ var KNOWN_BASELINE_IDS = /* @__PURE__ */ new Set([
29
+ "KT-MOD-0001",
30
+ // tech-stack
31
+ "KT-MOD-0002",
32
+ // module-structure
33
+ "KT-MOD-0003",
34
+ // readme-first-paragraph
35
+ "KT-PRO-0001",
36
+ // build-config
37
+ "KT-PRO-0002",
38
+ // ci-config (allocated after build-config in the deterministic order)
39
+ "KT-GLD-0001"
40
+ // code-style
41
+ ]);
42
+ var KNOWN_BASELINE_SLUGS = /* @__PURE__ */ new Set([
43
+ "tech-stack",
44
+ "module-structure",
45
+ "build-config",
46
+ "code-style",
47
+ "ci-config",
48
+ "readme-first-paragraph",
49
+ "project-brief"
50
+ ]);
51
+ var ID_PREFIXED_FILENAME_PATTERN = /^KT-[A-Z]+-\d+--.+\.md$/u;
28
52
  async function runInitScan(targetInput, options = {}) {
29
53
  const startTs = Date.now();
30
54
  const target = normalizeTarget(targetInput);
@@ -32,20 +56,20 @@ async function runInitScan(targetInput, options = {}) {
32
56
  if (!existsSync(forensicPath)) {
33
57
  throw new Error(t("cli.scan.error.missing-forensic", { path: forensicPath }));
34
58
  }
59
+ await migrateLegacyBaselineFilenames(target);
35
60
  const forensic = await readForensic(forensicPath);
36
61
  const nowIso = (options.now ?? /* @__PURE__ */ new Date()).toISOString();
37
- const tags = deriveTagsFromForensic(forensic);
38
62
  const fabricConfig = readFabricConfig(target);
39
63
  const fabricLanguage = fabricConfig.fabric_language ?? "match-existing";
40
64
  const resolvedLanguage = resolveFabricLanguage(fabricLanguage, target);
41
65
  const candidates = [
42
- buildTechStackEntry(forensic, nowIso, tags, resolvedLanguage),
43
- buildModuleStructureEntry(forensic, nowIso, tags, resolvedLanguage),
44
- buildBuildConfigEntry(forensic, nowIso, tags, resolvedLanguage),
45
- buildCodeStyleEntry(forensic, nowIso, tags, resolvedLanguage),
46
- buildCIConfigEntry(forensic, nowIso, tags),
47
- buildReadmeFirstParaEntry(target, forensic, nowIso, tags, resolvedLanguage),
48
- buildProjectBriefEntry(target, forensic, nowIso, tags)
66
+ buildTechStackEntry(forensic, nowIso, resolvedLanguage),
67
+ buildModuleStructureEntry(forensic, nowIso, resolvedLanguage),
68
+ buildBuildConfigEntry(forensic, nowIso, resolvedLanguage),
69
+ buildCodeStyleEntry(forensic, nowIso, resolvedLanguage),
70
+ buildCIConfigEntry(forensic, nowIso),
71
+ buildReadmeFirstParaEntry(target, forensic, nowIso, resolvedLanguage),
72
+ buildProjectBriefEntry(target, forensic, nowIso)
49
73
  ];
50
74
  const entries = candidates.filter((e) => e !== null);
51
75
  const sidecarPath = join(target, KNOWLEDGE_DIR, SCAN_STATE_FILE);
@@ -55,11 +79,12 @@ async function runInitScan(targetInput, options = {}) {
55
79
  const skipped = [];
56
80
  const placedEntries = [];
57
81
  for (const entry of entries) {
58
- const targetPath = join(target, KNOWLEDGE_DIR, entry.target_subdir, `${entry.slug}.md`);
59
- const existingId = findExistingIdForFile(sidecar, targetPath, target);
82
+ const subdirAbs = join(target, KNOWLEDGE_DIR, entry.target_subdir);
83
+ const existingId = findExistingIdBySlug(sidecar, subdirAbs, entry.slug);
60
84
  const id = existingId ?? await allocator.allocate(entry.layer, entry.type);
61
85
  const built = { ...entry, id };
62
86
  placedEntries.push(built);
87
+ const targetPath = join(subdirAbs, `${id}--${entry.slug}.md`);
63
88
  const fullContent = renderMarkdown(built);
64
89
  const bodyHash = sha256(stripFrontmatter(fullContent));
65
90
  const sidecarKey = id;
@@ -303,7 +328,7 @@ function detectExistingLanguage(target) {
303
328
  const ratio = cjkCount / denominator;
304
329
  return ratio > ZH_CN_RATIO_THRESHOLD ? "zh-CN-hybrid" : "en";
305
330
  }
306
- function buildTechStackEntry(forensic, nowIso, tags, language = "en") {
331
+ function buildTechStackEntry(forensic, nowIso, language = "en") {
307
332
  const framework = forensic.framework;
308
333
  const byExt = forensic.topology.by_ext ?? {};
309
334
  const topExtensions = Object.entries(byExt).sort(([, a], [, b]) => b - a).slice(0, 5).map(([ext, count]) => `${ext} (${count})`);
@@ -330,12 +355,12 @@ function buildTechStackEntry(forensic, nowIso, tags, language = "en") {
330
355
  body,
331
356
  target_subdir: "models",
332
357
  slug: "tech-stack",
333
- tags,
358
+ tags: [],
334
359
  relevance_scope: "narrow",
335
360
  relevance_paths: relevancePaths
336
361
  };
337
362
  }
338
- function buildModuleStructureEntry(forensic, nowIso, tags, language = "en") {
363
+ function buildModuleStructureEntry(forensic, nowIso, language = "en") {
339
364
  const keyDirs = forensic.topology.key_dirs ?? [];
340
365
  const entryPoints = forensic.entry_points ?? [];
341
366
  const totalFiles = forensic.topology.total_files ?? 0;
@@ -361,12 +386,12 @@ function buildModuleStructureEntry(forensic, nowIso, tags, language = "en") {
361
386
  body,
362
387
  target_subdir: "models",
363
388
  slug: "module-structure",
364
- tags,
389
+ tags: [],
365
390
  relevance_scope: "narrow",
366
391
  relevance_paths: relevancePaths
367
392
  };
368
393
  }
369
- function buildBuildConfigEntry(forensic, nowIso, tags, language = "en") {
394
+ function buildBuildConfigEntry(forensic, nowIso, language = "en") {
370
395
  const configFiles = (forensic.candidate_files ?? []).filter((entry) => entry.family === "config").map((entry) => entry.path);
371
396
  const framework = forensic.framework.kind;
372
397
  const configBlock = configFiles.length > 0 ? configFiles.map((file) => `- ${file}`).join("\n") : "- (no config files detected)";
@@ -389,12 +414,12 @@ function buildBuildConfigEntry(forensic, nowIso, tags, language = "en") {
389
414
  body,
390
415
  target_subdir: "processes",
391
416
  slug: "build-config",
392
- tags,
417
+ tags: [],
393
418
  relevance_scope: "narrow",
394
419
  relevance_paths: relevancePaths
395
420
  };
396
421
  }
397
- function buildCodeStyleEntry(forensic, nowIso, tags, language = "en") {
422
+ function buildCodeStyleEntry(forensic, nowIso, language = "en") {
398
423
  const dominantPatterns = (forensic.assertions ?? []).filter((a) => a.type === "pattern" || a.type === "domain").slice(0, 4).map((a) => `- ${a.statement}`);
399
424
  const proposedRules = (forensic.assertions ?? []).map((a) => a.proposed_rule).filter((rule) => typeof rule === "string" && rule.length > 0).slice(0, 4);
400
425
  const patternsBlock = dominantPatterns.length > 0 ? dominantPatterns.join("\n") : "- (no dominant patterns detected)";
@@ -424,12 +449,12 @@ function buildCodeStyleEntry(forensic, nowIso, tags, language = "en") {
424
449
  body,
425
450
  target_subdir: "guidelines",
426
451
  slug: "code-style",
427
- tags,
452
+ tags: [],
428
453
  relevance_scope: "narrow",
429
454
  relevance_paths: relevancePaths
430
455
  };
431
456
  }
432
- function buildCIConfigEntry(forensic, nowIso, tags) {
457
+ function buildCIConfigEntry(forensic, nowIso) {
433
458
  const ciFiles = (forensic.candidate_files ?? []).map((entry) => entry.path).filter((path) => isCIConfigPath(path));
434
459
  const ciExtensions = forensic.topology.by_ext ?? {};
435
460
  const hasCISignal = ciFiles.length > 0 || Object.keys(ciExtensions).some((ext) => ext === ".yml" || ext === ".yaml") && (forensic.assertions ?? []).some((a) => /ci|workflow|pipeline/i.test(a.statement));
@@ -466,12 +491,12 @@ function buildCIConfigEntry(forensic, nowIso, tags) {
466
491
  body,
467
492
  target_subdir: "processes",
468
493
  slug: "ci-config",
469
- tags,
494
+ tags: [],
470
495
  relevance_scope: "narrow",
471
496
  relevance_paths: relevancePaths
472
497
  };
473
498
  }
474
- function buildReadmeFirstParaEntry(target, forensic, nowIso, tags, language = "en") {
499
+ function buildReadmeFirstParaEntry(target, forensic, nowIso, language = "en") {
475
500
  if (forensic.readme.quality === "missing") {
476
501
  return null;
477
502
  }
@@ -501,7 +526,7 @@ function buildReadmeFirstParaEntry(target, forensic, nowIso, tags, language = "e
501
526
  body,
502
527
  target_subdir: "models",
503
528
  slug: "readme-first-paragraph",
504
- tags,
529
+ tags: [],
505
530
  // v2.0-rc.7 T2: broad by design — single repo-root file, the Phase 1.5
506
531
  // PreToolUse blacklist already covers README. Anchoring this entry to
507
532
  // README.md would surface it on every README edit, which is noise.
@@ -509,7 +534,7 @@ function buildReadmeFirstParaEntry(target, forensic, nowIso, tags, language = "e
509
534
  relevance_paths: []
510
535
  };
511
536
  }
512
- function buildProjectBriefEntry(target, forensic, nowIso, tags) {
537
+ function buildProjectBriefEntry(target, forensic, nowIso) {
513
538
  if (forensic.readme.quality === "missing") {
514
539
  return null;
515
540
  }
@@ -541,7 +566,7 @@ function buildProjectBriefEntry(target, forensic, nowIso, tags) {
541
566
  body,
542
567
  target_subdir: "models",
543
568
  slug: "project-brief",
544
- tags,
569
+ tags: [],
545
570
  // v2.0-rc.7 T2: broad — project brief is a cross-cutting description
546
571
  // with no path anchor. Narrowing it to README.md would duplicate the
547
572
  // readme-first-paragraph surface; keeping it broad lets the
@@ -603,47 +628,6 @@ function quoteIfNeeded(value) {
603
628
  function stripFrontmatter(content) {
604
629
  return content.replace(/^---[\s\S]*?\r?\n---\s*\r?\n?/u, "");
605
630
  }
606
- function deriveTagsFromForensic(forensic) {
607
- const MAX_TAGS = 5;
608
- const seen = /* @__PURE__ */ new Set();
609
- const tags = [];
610
- function add(raw) {
611
- const normalized = raw.toLowerCase().trim().replace(/\s+/gu, "-");
612
- if (normalized.length > 0 && !seen.has(normalized)) {
613
- seen.add(normalized);
614
- tags.push(normalized);
615
- }
616
- }
617
- if (forensic.framework.kind) {
618
- add(forensic.framework.kind);
619
- }
620
- const SKIP_EXTS = /* @__PURE__ */ new Set([".json", ".md", ".lock", ".yaml", ".yml", ".txt", ".env"]);
621
- const EXT_MAP = {
622
- ".ts": "typescript",
623
- ".tsx": "typescript",
624
- ".js": "javascript",
625
- ".jsx": "javascript",
626
- ".mjs": "javascript",
627
- ".cjs": "javascript",
628
- ".py": "python",
629
- ".go": "go",
630
- ".rs": "rust",
631
- ".java": "java",
632
- ".cs": "csharp",
633
- ".rb": "ruby",
634
- ".php": "php",
635
- ".swift": "swift",
636
- ".kt": "kotlin"
637
- };
638
- const byExt = forensic.topology.by_ext ?? {};
639
- const sorted = Object.entries(byExt).filter(([ext]) => !SKIP_EXTS.has(ext)).sort(([, a], [, b]) => b - a);
640
- for (const [ext] of sorted) {
641
- if (tags.length >= MAX_TAGS) break;
642
- const mapped = EXT_MAP[ext] ?? ext.replace(/^\./u, "");
643
- add(mapped);
644
- }
645
- return tags.slice(0, MAX_TAGS);
646
- }
647
631
  async function readForensic(forensicPath) {
648
632
  const raw = await readFile(forensicPath, "utf8");
649
633
  return JSON.parse(raw);
@@ -669,29 +653,143 @@ async function readScanState(sidecarPath) {
669
653
  return {};
670
654
  }
671
655
  }
672
- function findExistingIdForFile(sidecar, targetPath, target) {
673
- if (!existsSync(targetPath)) {
656
+ async function migrateLegacyBaselineFilenames(target) {
657
+ const knowledgeRoot = join(target, KNOWLEDGE_DIR);
658
+ if (!existsSync(knowledgeRoot)) {
659
+ return { migrated: [] };
660
+ }
661
+ const migrated = [];
662
+ const subdirs = ["models", "guidelines", "processes"];
663
+ for (const sub of subdirs) {
664
+ const subdirPath = join(knowledgeRoot, sub);
665
+ if (!existsSync(subdirPath)) continue;
666
+ let entries;
667
+ try {
668
+ entries = readdirSync(subdirPath);
669
+ } catch {
670
+ continue;
671
+ }
672
+ for (const name of entries) {
673
+ if (!name.endsWith(".md")) continue;
674
+ if (ID_PREFIXED_FILENAME_PATTERN.test(name)) {
675
+ const idMatch = /^(KT-[A-Z]+-\d+)--(.+)\.md$/u.exec(name);
676
+ if (idMatch === null) continue;
677
+ const [, fileId, fileSlug] = idMatch;
678
+ if (!KNOWN_BASELINE_IDS.has(fileId)) continue;
679
+ if (!KNOWN_BASELINE_SLUGS.has(fileSlug)) continue;
680
+ const onDiskPath = join(subdirPath, name);
681
+ let onDiskRaw;
682
+ try {
683
+ onDiskRaw = readFileSync(onDiskPath, "utf8");
684
+ } catch {
685
+ continue;
686
+ }
687
+ const scrubbed = stripStaleTagsLine(onDiskRaw);
688
+ if (scrubbed !== onDiskRaw) {
689
+ await atomicWriteText(onDiskPath, scrubbed);
690
+ }
691
+ continue;
692
+ }
693
+ const bareSlug = name.slice(0, -".md".length);
694
+ if (!KNOWN_BASELINE_SLUGS.has(bareSlug)) continue;
695
+ const oldPath = join(subdirPath, name);
696
+ let raw;
697
+ try {
698
+ raw = readFileSync(oldPath, "utf8");
699
+ } catch {
700
+ continue;
701
+ }
702
+ const id = extractFrontmatterId(raw);
703
+ if (id === null || !KNOWN_BASELINE_IDS.has(id)) continue;
704
+ const newName = `${id}--${bareSlug}.md`;
705
+ const newPath = join(subdirPath, newName);
706
+ const cleanedRaw = stripStaleTagsLine(raw);
707
+ if (existsSync(newPath)) {
708
+ try {
709
+ await unlink(oldPath);
710
+ } catch {
711
+ }
712
+ continue;
713
+ }
714
+ await atomicWriteText(newPath, cleanedRaw);
715
+ try {
716
+ await unlink(oldPath);
717
+ } catch {
718
+ }
719
+ migrated.push({ from: oldPath, to: newPath, id });
720
+ }
721
+ }
722
+ return { migrated };
723
+ }
724
+ function stripStaleTagsLine(raw) {
725
+ const fmMatch = /^(---\r?\n)([\s\S]*?)(\r?\n---\s*(?:\r?\n|$))/u.exec(raw);
726
+ if (fmMatch === null) return raw;
727
+ const head = fmMatch[1];
728
+ const body = fmMatch[2];
729
+ const tail = fmMatch[3];
730
+ const rest = raw.slice(fmMatch[0].length);
731
+ const flowPattern = /^tags:[ \t]*\[[^\n]*\][ \t]*$/mu;
732
+ if (flowPattern.test(body)) {
733
+ const replaced = body.replace(flowPattern, "tags: []");
734
+ return `${head}${replaced}${tail}${rest}`;
735
+ }
736
+ const blockPattern = /^tags:[ \t]*\r?\n(?:[ \t]+-[ \t]+.+\r?\n?)+/mu;
737
+ if (blockPattern.test(body)) {
738
+ const replaced = body.replace(blockPattern, "tags: []\n");
739
+ return `${head}${replaced.replace(/\n{2,}$/u, "\n")}${tail}${rest}`;
740
+ }
741
+ const barePattern = /^tags:[ \t]*$/mu;
742
+ if (barePattern.test(body)) {
743
+ const replaced = body.replace(barePattern, "tags: []");
744
+ return `${head}${replaced}${tail}${rest}`;
745
+ }
746
+ return raw;
747
+ }
748
+ function extractFrontmatterId(raw) {
749
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(raw);
750
+ if (match === null) return null;
751
+ const idLine = /^id:\s*(.+)$/mu.exec(match[1]);
752
+ if (idLine === null) return null;
753
+ return idLine[1].replace(/^["'](.*)["']$/u, "$1").trim();
754
+ }
755
+ function findExistingIdBySlug(sidecar, subdirAbs, slug) {
756
+ if (!existsSync(subdirAbs)) {
674
757
  return null;
675
758
  }
759
+ let entries;
676
760
  try {
677
- const raw = readFileSync(targetPath, "utf8");
678
- const match = /^---\r?\n([\s\S]*?)\r?\n---/u.exec(raw);
679
- if (match === null) {
680
- return null;
681
- }
682
- const idLine = /^id:\s*(.+)$/mu.exec(match[1]);
683
- if (idLine === null) {
761
+ entries = readdirSync(subdirAbs);
762
+ } catch {
763
+ return null;
764
+ }
765
+ const escapedSlug = slug.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
766
+ const pattern = new RegExp(`^(KT-[A-Z]+-\\d+)--${escapedSlug}\\.md$`, "u");
767
+ const matches = [];
768
+ for (const name of entries) {
769
+ const m = pattern.exec(name);
770
+ if (m === null) continue;
771
+ matches.push({ id: m[1], file: name });
772
+ }
773
+ if (matches.length !== 1) {
774
+ return null;
775
+ }
776
+ const filenameId = matches[0].id;
777
+ try {
778
+ const raw = readFileSync(join(subdirAbs, matches[0].file), "utf8");
779
+ const frontmatterId = extractFrontmatterId(raw);
780
+ if (frontmatterId !== filenameId) {
684
781
  return null;
685
782
  }
686
- const candidate = idLine[1].replace(/^["'](.*)["']$/u, "$1").trim();
687
- if (/^K[PT]-(MOD|DEC|GLD|PIT|PRO)-\d{4,}$/.test(candidate) && sidecar[candidate] !== void 0) {
688
- return candidate;
689
- }
690
- return null;
691
783
  } catch {
692
- void target;
693
784
  return null;
694
785
  }
786
+ if (!/^K[PT]-(MOD|DEC|GLD|PIT|PRO)-\d{4,}$/u.test(filenameId)) {
787
+ return null;
788
+ }
789
+ if (sidecar[filenameId] === void 0) {
790
+ return null;
791
+ }
792
+ return filenameId;
695
793
  }
696
794
  function isCIConfigPath(path) {
697
795
  return path.startsWith(".github/workflows/") || path.startsWith(".gitlab-ci") || path === "azure-pipelines.yml" || path === ".circleci/config.yml" || path === "Jenkinsfile" || path === ".travis.yml";
@@ -759,7 +857,7 @@ async function registerKnowledgeNodesInMeta(target, entries) {
759
857
  }
760
858
  const nodes = typeof meta.nodes === "object" && meta.nodes !== null ? meta.nodes : {};
761
859
  for (const entry of entries) {
762
- const contentRef = `${KNOWLEDGE_DIR}/${entry.target_subdir}/${entry.slug}.md`;
860
+ const contentRef = `${KNOWLEDGE_DIR}/${entry.target_subdir}/${entry.id}--${entry.slug}.md`;
763
861
  const absPath = join(target, contentRef);
764
862
  let hash = "";
765
863
  try {
@@ -16,13 +16,10 @@ function readFabricConfig(workspaceRoot = process.cwd()) {
16
16
  }
17
17
  function resolveDevMode(cliTarget, workspaceRoot = process.cwd()) {
18
18
  const envTarget = normalizeTarget(process.env.EXTERNAL_FIXTURE_PATH, workspaceRoot);
19
- const fabricConfig = readFabricConfig(workspaceRoot);
20
- const configTarget = normalizeTarget(fabricConfig.externalFixturePath, workspaceRoot);
21
19
  const directTarget = normalizeTarget(cliTarget, workspaceRoot);
22
20
  const chain = [
23
21
  formatResolutionStep("cliTarget", directTarget),
24
22
  formatResolutionStep("EXTERNAL_FIXTURE_PATH", envTarget),
25
- formatResolutionStep("fabric.config.json.externalFixturePath", configTarget),
26
23
  formatResolutionStep("process.cwd()", workspaceRoot)
27
24
  ];
28
25
  if (directTarget !== void 0) {
@@ -31,9 +28,6 @@ function resolveDevMode(cliTarget, workspaceRoot = process.cwd()) {
31
28
  if (envTarget !== void 0) {
32
29
  return { target: envTarget, source: "env", chain };
33
30
  }
34
- if (configTarget !== void 0) {
35
- return { target: configTarget, source: "config", chain };
36
- }
37
31
  return { target: workspaceRoot, source: "cwd", chain };
38
32
  }
39
33
  function createDebugLogger(debug) {