@fenglimg/fabric-cli 2.0.0-rc.21 → 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.
@@ -9,7 +9,7 @@ import {
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 {
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runInitScan
4
- } from "./chunk-FNO7CQDG.js";
4
+ } from "./chunk-PSVKSMRO.js";
5
5
  import {
6
6
  hasActionHint,
7
7
  paint,
package/dist/index.js CHANGED
@@ -11,19 +11,19 @@ import { defineCommand, runMain } from "citty";
11
11
 
12
12
  // src/commands/index.ts
13
13
  var allCommands = {
14
- install: () => import("./install-DNZXGFHJ.js").then((module) => module.default),
15
- doctor: () => import("./doctor-L6TIXXIX.js").then((module) => module.default),
14
+ install: () => import("./install-WJZQZM7D.js").then((module) => module.default),
15
+ doctor: () => import("./doctor-HIX2FFEP.js").then((module) => module.default),
16
16
  serve: () => import("./serve-6PPQX7AW.js").then((module) => module.default),
17
17
  uninstall: () => import("./uninstall-L2HEEOU3.js").then((module) => module.default),
18
18
  config: () => import("./config-AYP5F72E.js").then((module) => module.default),
19
- "plan-context-hint": () => import("./plan-context-hint-CFDGXHCA.js").then((module) => module.default)
19
+ "plan-context-hint": () => import("./plan-context-hint-RYVSMULL.js").then((module) => module.default)
20
20
  };
21
21
 
22
22
  // src/index.ts
23
23
  var main = defineCommand({
24
24
  meta: {
25
25
  name: "fabric",
26
- version: "2.0.0-rc.21",
26
+ version: "2.0.0-rc.22",
27
27
  description: t("cli.main.description")
28
28
  },
29
29
  subCommands: allCommands
@@ -5,7 +5,7 @@ import {
5
5
  import {
6
6
  detectExistingLanguage,
7
7
  runInitScan
8
- } from "./chunk-FNO7CQDG.js";
8
+ } from "./chunk-PSVKSMRO.js";
9
9
  import {
10
10
  installArchiveHintHook,
11
11
  installFabricArchiveSkill,
@@ -1299,7 +1299,7 @@ function readProjectName(target) {
1299
1299
  return basename(target);
1300
1300
  }
1301
1301
  function getCliVersion() {
1302
- return true ? "2.0.0-rc.21" : "unknown";
1302
+ return true ? "2.0.0-rc.22" : "unknown";
1303
1303
  }
1304
1304
  function sortRecord(record) {
1305
1305
  return Object.fromEntries(Object.entries(record).sort(([left], [right]) => left.localeCompare(right)));
@@ -72,13 +72,20 @@ async function runPlanContextHint(opts) {
72
72
  maturity: item.maturity ?? item.description.maturity ?? "",
73
73
  summary: item.description.summary
74
74
  }));
75
- return {
75
+ const output = {
76
76
  version: 2,
77
77
  revision_hash: result.revision_hash,
78
78
  target_paths: targetPaths,
79
79
  entries,
80
80
  broad_count: sharedIndex.length
81
81
  };
82
+ if (result.auto_healed === true) {
83
+ output.auto_healed = true;
84
+ if (typeof result.previous_revision_hash === "string") {
85
+ output.previous_revision_hash = result.previous_revision_hash;
86
+ }
87
+ }
88
+ return output;
82
89
  }
83
90
  function parsePathsArg(raw) {
84
91
  if (raw === void 0 || raw.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fenglimg/fabric-cli",
3
- "version": "2.0.0-rc.21",
3
+ "version": "2.0.0-rc.22",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "fab": "dist/index.js",
@@ -20,8 +20,8 @@
20
20
  "tree-sitter-javascript": "^0.25.0",
21
21
  "tree-sitter-typescript": "^0.23.2",
22
22
  "web-tree-sitter": "^0.26.8",
23
- "@fenglimg/fabric-server": "2.0.0-rc.21",
24
- "@fenglimg/fabric-shared": "2.0.0-rc.21"
23
+ "@fenglimg/fabric-server": "2.0.0-rc.22",
24
+ "@fenglimg/fabric-shared": "2.0.0-rc.22"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^22.15.0",
@@ -476,6 +476,46 @@ function renderSummary(payload) {
476
476
  if (revHash !== null && revHash.length > 0) {
477
477
  lines.push(` revision_hash: ${revHash}`);
478
478
  }
479
+
480
+ // rc.22 Scope D T-D4 (TASK-011): meta auto-refresh breadcrumb. Emitted ONLY
481
+ // when the server's planContext() detected meta drift and rebuilt the meta
482
+ // in-place (auto_healed === true). One informational line — operators need
483
+ // a paper trail when revision_hash flips mid-session.
484
+ //
485
+ // Variant resolution:
486
+ // - Both hashes present → full transition line (`sha PREV → CUR`) with
487
+ // 8-char hex prefixes stripped of the `sha256:` scheme prefix.
488
+ // - auto_healed:true but previous_revision_hash missing → generic line
489
+ // (T10 noted the server may emit `auto_healed:true` alone if it lost
490
+ // the prior hash for any reason). Stays informational.
491
+ //
492
+ // i18n: routed through renderBanner so zh-CN / en / zh-CN-hybrid variants
493
+ // share one call site. fabric_language is resolved via readFabricLanguage()
494
+ // ONLY when the line is actually emitted (keeps the no-banner path free of
495
+ // the extra config read, matching the broadImportBanner site below).
496
+ if (payload.auto_healed === true) {
497
+ const variant = readFabricLanguage(process.cwd());
498
+ const prevRaw =
499
+ typeof payload.previous_revision_hash === "string"
500
+ ? payload.previous_revision_hash
501
+ : null;
502
+ const curRaw =
503
+ typeof payload.revision_hash === "string" ? payload.revision_hash : null;
504
+ if (prevRaw && curRaw) {
505
+ // Strip optional `sha256:` scheme prefix, then take first 8 hex chars.
506
+ const stripScheme = (h) =>
507
+ h.startsWith("sha256:") ? h.slice("sha256:".length) : h;
508
+ const prev = stripScheme(prevRaw).slice(0, 8);
509
+ const cur = stripScheme(curRaw).slice(0, 8);
510
+ lines.push(
511
+ renderBanner("metaAutoRefreshedBanner", variant, { prev, cur }),
512
+ );
513
+ } else {
514
+ // Defensive: auto_healed:true but no usable previous hash → generic line.
515
+ lines.push(renderBanner("metaAutoRefreshedBannerGeneric", variant, {}));
516
+ }
517
+ }
518
+
479
519
  lines.push(" Use `fab_get_knowledge_sections` to fetch full content.");
480
520
  return lines;
481
521
  }
@@ -223,6 +223,36 @@ const STRINGS = {
223
223
  "zh-CN-hybrid": () =>
224
224
  " 📋 Fabric: 知识库稀疏,是否调 /fabric-import 从 git 历史与现有文档回灌知识?",
225
225
  },
226
+
227
+ // ---- Broad hook: meta auto-refresh breadcrumb (rc.22 Scope D T-D4) -------
228
+ // Surfaced ONLY when planContext() detected meta drift and rebuilt the meta
229
+ // in-place (server emits `auto_healed: true` in plan-context-hint payload).
230
+ // Single informational line — operators need a breadcrumb when meta auto-
231
+ // heals so a "why did revision change?" question has a paper trail.
232
+ //
233
+ // Two render shapes:
234
+ // - metaAutoRefreshedBanner: full transition with prev → cur 8-char hash
235
+ // prefixes. Used when both previous_revision_hash + revision_hash present.
236
+ // - metaAutoRefreshedBannerGeneric: defensive fallback when the server
237
+ // emitted `auto_healed: true` but did not include previous_revision_hash
238
+ // (T10 noted this edge case). No hash transition shown.
239
+ //
240
+ // Note: 🔄 emoji prefix is intentional (matches the project's general "no
241
+ // emoji" rule's exception for explicit user request — see TASK-011 description).
242
+ // params: { prev, cur } — both already 8-char hex strings, caller-supplied.
243
+ metaAutoRefreshedBanner: {
244
+ "zh-CN": (p) => ` 🔄 Fabric: 元数据已自动刷新(sha ${p.prev} → ${p.cur})`,
245
+ en: (p) => ` 🔄 Fabric: meta auto-refreshed (sha ${p.prev} → ${p.cur})`,
246
+ "zh-CN-hybrid": (p) => ` 🔄 Fabric: 元数据已自动刷新(sha ${p.prev} → ${p.cur})`,
247
+ },
248
+
249
+ // Generic variant — no hash transition. Used when auto_healed:true but
250
+ // previous_revision_hash is missing from the payload.
251
+ metaAutoRefreshedBannerGeneric: {
252
+ "zh-CN": () => " 🔄 Fabric: 元数据已自动刷新",
253
+ en: () => " 🔄 Fabric: meta auto-refreshed",
254
+ "zh-CN-hybrid": () => " 🔄 Fabric: 元数据已自动刷新",
255
+ },
226
256
  };
227
257
 
228
258
  /**