@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.
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- const { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
2
+ const { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } = require("node:fs");
3
3
  const { dirname, join } = require("node:path");
4
4
 
5
5
  // v2.0.0-rc.7 T5: session-digest writer. Best-effort (never blocks Stop hook
@@ -13,6 +13,15 @@ try {
13
13
  sessionDigestWriter = null;
14
14
  }
15
15
 
16
+ // v2.0.0-rc.16 TASK-002 (F2-apply): banner-i18n lib for the 5 Signal
17
+ // banners (A/B/C/D-never/D-aged). Resolved ONCE per main() invocation and
18
+ // threaded into decide() / evaluateMaintenanceSignal() via the existing
19
+ // thresholds object. Lib is required at module load; failure to load is
20
+ // fatal-here-but-silent: the require itself can't throw without the .cjs
21
+ // being missing entirely (a packaging bug we'd want to surface during
22
+ // install integration tests, not silently swallow).
23
+ const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
24
+
16
25
  // CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
17
26
  // DRY violation accepted: this hook script runs in user repos WITHOUT
18
27
  // node_modules access, so it cannot import from @fenglimg/fabric-server.
@@ -22,6 +31,11 @@ const EVENT_TYPE_PROPOSED = "knowledge_proposed";
22
31
  const EVENT_TYPE_INIT_SCAN_COMPLETED = "init_scan_completed";
23
32
  // v2.0.0-rc.7 T10: doctor_run event drives Signal D (maintenance hint).
24
33
  const EVENT_TYPE_DOCTOR_RUN = "doctor_run";
34
+ // v2.0.0-rc.20 TASK-03: per-turn cite-policy observation event. Emitted by
35
+ // extractAndWriteAssistantTurnsBestEffort() after the Stop hook parses each
36
+ // assistant envelope's first non-empty line for a `KB:` prefix. Schema
37
+ // registered in packages/shared/src/schemas/event-ledger.ts (rc.20 TASK-02).
38
+ const EVENT_TYPE_ASSISTANT_TURN_OBSERVED = "assistant_turn_observed";
25
39
  // rc.6 TASK-022 (E5): Signal A is now `24h OR N-edits since last
26
40
  // knowledge_proposed`. The edit-count branch reads
27
41
  // `.fabric/.cache/edit-counter` (one ISO-8601 line per PreToolUse fire,
@@ -556,6 +570,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
556
570
  typeof cfg.reviewHintPendingAgeDays === "number" && cfg.reviewHintPendingAgeDays > 0
557
571
  ? cfg.reviewHintPendingAgeDays
558
572
  : DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS;
573
+ // rc.16 TASK-002: banner variant for the i18n lib. Defaults to 'zh-CN' so
574
+ // existing test callers (which never pass thresholds.variant) get the rc.15
575
+ // byte-identical Chinese output. main() always supplies the resolved variant.
576
+ const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
559
577
 
560
578
  // ---- Archive signal (rc.6 TASK-022 — Signal A, 24h-OR-N-edits) -----------
561
579
  // Locate the most-recent knowledge_proposed event. If none exists, Signal A
@@ -611,14 +629,17 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
611
629
  `累计 ${editStats.editsSinceLastProposed} 次编辑(阈值 ${editStats.threshold})`,
612
630
  );
613
631
  }
614
- const line1 = `📋 Fabric: 距上次归档 ${parts.join(" / ")}。`;
632
+ // rc.16 TASK-002: 5-banner i18n via lib/banner-i18n.cjs. Substring
633
+ // contracts ('25.0h', '阈值 N', 'fabric-archive') preserved by the lib's
634
+ // zh-CN templates — see lib header for the full contract.
635
+ const line1 = renderBanner("archiveLine1", variant, { parts: parts.join(" / ") });
615
636
  const activity = banner && typeof banner.activityOverview === "string"
616
637
  ? banner.activityOverview
617
638
  : "";
618
639
  const line2 = activity.length > 0
619
- ? ` 最近活动集中在: ${activity}。`
640
+ ? renderBanner("archiveActivity", variant, { activity })
620
641
  : "";
621
- const line3 = " 是否调 /fabric-archive 检查值得归档的决策/踩坑/复用?";
642
+ const line3 = renderBanner("archiveCta", variant, {});
622
643
  const reason = [line1, line2, line3].filter((l) => l.length > 0).join("\n");
623
644
  return {
624
645
  decision: "block",
@@ -648,8 +669,13 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
648
669
  stats.oldestAgeMs !== null
649
670
  ? ` / 最早一条 ${(stats.oldestAgeMs / MS_PER_DAY).toFixed(1)} 天前`
650
671
  : "";
651
- const line1 = `📋 Fabric: 已积累 ${stats.count} 条待审核知识${ageSuffix}。`;
652
- const line2 = " 是否调 /fabric-review 审核 pending/ 条目?";
672
+ // rc.16 TASK-002: i18n via lib. Substrings ('${count} 条', 'fabric-review')
673
+ // preserved by the lib's zh-CN templates.
674
+ const line1 = renderBanner("reviewLine1", variant, {
675
+ count: stats.count,
676
+ ageSuffix,
677
+ });
678
+ const line2 = renderBanner("reviewCta", variant, {});
653
679
  const reason = `${line1}\n${line2}`;
654
680
  return {
655
681
  decision: "block",
@@ -690,12 +716,16 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
690
716
  (hoursSinceProposed === null || hoursSinceProposed >= UNDERSEED_NO_PROPOSED_HOURS);
691
717
 
692
718
  if (triggerUnderseed) {
693
- // rc.7 T4: 人-first banner reformat for Signal C. Preserves the
694
- // `${nodeCount}/${threshold}` substring (e.g. "3/10") that existing
695
- // tests assert against; drops Agent-jussive phrasing.
696
- const line1 =
697
- `📋 Fabric: 知识库节点数 ${underseed.nodeCount}/${underseed.threshold},距 init_scan_completed ${hoursSinceInit.toFixed(1)}h。`;
698
- const line2 = " 是否调 /fabric-import 从 git 历史与现有文档回灌知识?";
719
+ // rc.16 TASK-002: i18n via lib. Substrings ('${nodeCount}/${threshold}',
720
+ // 'fabric-import', '${hoursSinceInit}h') preserved by the lib's zh-CN
721
+ // templates. Note: hoursSinceInit is passed as already-toFixed(1) string
722
+ // to keep the lib pure (no number formatting in render path).
723
+ const line1 = renderBanner("importLine1", variant, {
724
+ nodeCount: underseed.nodeCount,
725
+ threshold: underseed.threshold,
726
+ hoursSinceInit: hoursSinceInit.toFixed(1),
727
+ });
728
+ const line2 = renderBanner("importCta", variant, {});
699
729
  const reason = `${line1}\n${line2}`;
700
730
  return {
701
731
  decision: "block",
@@ -898,6 +928,10 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
898
928
  typeof cfg.maintenanceHintCooldownDays === "number" && cfg.maintenanceHintCooldownDays > 0
899
929
  ? cfg.maintenanceHintCooldownDays
900
930
  : DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS;
931
+ // rc.16 TASK-002: banner variant for the i18n lib. Defaults to 'zh-CN' so
932
+ // existing rc.7 T10 test fixtures (which never set thresholds.variant) get
933
+ // the byte-identical Chinese maintenance banner.
934
+ const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
901
935
 
902
936
  if (canonicalCount < MAINTENANCE_HINT_MIN_CANONICAL) {
903
937
  return null;
@@ -922,14 +956,18 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
922
956
  if (ageDays < days) return null; // doctor ran recently, no nag.
923
957
  }
924
958
 
925
- // rc.7 T4: keep the existing T10 banner shape (already 人-first with the
926
- // 📋 prefix), but split the action-prompt onto its own line for visual
927
- // consistency with Signals A/B/C. Substrings ("从未运行 lint 检查",
928
- // "已 N 天未跑 lint", "fabric doctor --lint") preserved for the T10 tests.
929
- const line2 = " 是否调 `fabric doctor --lint` 看看知识库健康度?";
930
- const reason = lastDoctorTs === null
931
- ? `📋 Fabric: 从未运行 lint 检查。\n${line2}`
932
- : `📋 Fabric: 已 ${days} 天未跑 lint 检查(实际 ${ageDays.toFixed(1)}d)。\n${line2}`;
959
+ // rc.16 TASK-002: i18n via lib. Substrings ('从未运行 lint 检查',
960
+ // '已 N 天未跑 lint', 'fabric doctor --lint') preserved by the lib's
961
+ // zh-CN templates. ageDays passed as already-toFixed(1) string to keep
962
+ // the lib pure (no number formatting in render path).
963
+ const line2 = renderBanner("maintenanceLine2", variant, {});
964
+ const line1 = lastDoctorTs === null
965
+ ? renderBanner("maintenanceLine1Never", variant, {})
966
+ : renderBanner("maintenanceLine1Aged", variant, {
967
+ days,
968
+ ageDays: ageDays.toFixed(1),
969
+ });
970
+ const reason = `${line1}\n${line2}`;
933
971
 
934
972
  return {
935
973
  decision: "block",
@@ -966,6 +1004,204 @@ function tryReadStdinJson() {
966
1004
  }
967
1005
  }
968
1006
 
1007
+ /**
1008
+ * v2.0.0-rc.20 TASK-03: parse the raw text that follows the `KB:` prefix on
1009
+ * the first non-empty line of an assistant turn. Returns parsed cite ids and
1010
+ * the per-id tag enum vocabulary (planned/recalled/chained-from/dismissed/
1011
+ * none). Never throws; best-effort tolerant parser.
1012
+ *
1013
+ * Vocabulary contract (mirrored in
1014
+ * packages/shared/src/schemas/event-ledger.ts → assistantTurnObservedEventSchema):
1015
+ * - "none" → ids=[], tags=["none"]
1016
+ * - "KP-001" → ids=["KP-001"], tags=[]
1017
+ * - "KP-001, KT-DEC-0009 (review)" → ids=["KP-001","KT-DEC-0009"], tags=["review"]
1018
+ * - "KP-001 [recalled][chained-from KP-002]" →
1019
+ * ids=["KP-001"], tags=["recalled","chained-from"]
1020
+ * - "dismissed:<reason>" → ids=[], tags=["dismissed"]
1021
+ * (kb_line_raw preserves the full "dismissed:<reason>" verbatim;
1022
+ * the parsed `cite_tags` only carries the enum value "dismissed".)
1023
+ *
1024
+ * Tags are filtered to the Zod enum set
1025
+ * { planned, recalled, chained-from, dismissed, none } before being returned —
1026
+ * arbitrary parenthetical/bracket text outside the enum is dropped (silently)
1027
+ * so the emitted event always round-trips through the schema.
1028
+ */
1029
+ function parseKbLine(raw) {
1030
+ const result = { cite_ids: [], cite_tags: [] };
1031
+ if (typeof raw !== "string") return result;
1032
+ const trimmed = raw.trim();
1033
+ if (trimmed.length === 0) return result;
1034
+
1035
+ // dismissed:<reason> → tag="dismissed", no ids.
1036
+ if (/^dismissed:/i.test(trimmed)) {
1037
+ result.cite_tags.push("dismissed");
1038
+ return result;
1039
+ }
1040
+ // bare "none" → tag="none".
1041
+ if (/^none$/i.test(trimmed)) {
1042
+ result.cite_tags.push("none");
1043
+ return result;
1044
+ }
1045
+
1046
+ // Allowed tag enum (matches assistantTurnObservedEventSchema.cite_tags z.enum).
1047
+ const ALLOWED_TAGS = new Set(["planned", "recalled", "chained-from", "dismissed", "none"]);
1048
+ const tagSet = new Set();
1049
+
1050
+ // Extract bracketed tags: `[recalled]`, `[chained-from KP-002]`, etc.
1051
+ // We keep only the leading enum token (split on whitespace) so trailing
1052
+ // ref-ids inside the bracket are discarded — they're not part of the tag
1053
+ // vocabulary.
1054
+ const bracketRegex = /\[([^\]]+)\]/g;
1055
+ let bracketMatch;
1056
+ let stripped = trimmed;
1057
+ while ((bracketMatch = bracketRegex.exec(trimmed)) !== null) {
1058
+ const inner = bracketMatch[1].trim();
1059
+ if (inner.length === 0) continue;
1060
+ const head = inner.split(/\s+/)[0].toLowerCase();
1061
+ if (ALLOWED_TAGS.has(head)) tagSet.add(head);
1062
+ }
1063
+ stripped = stripped.replace(bracketRegex, " ");
1064
+
1065
+ // Extract parenthetical tags: `(review)`, `(planned)`, etc. Only enum
1066
+ // members are retained.
1067
+ const parenRegex = /\(([^)]+)\)/g;
1068
+ let parenMatch;
1069
+ while ((parenMatch = parenRegex.exec(trimmed)) !== null) {
1070
+ const inner = parenMatch[1].trim().toLowerCase();
1071
+ if (inner.length === 0) continue;
1072
+ if (ALLOWED_TAGS.has(inner)) tagSet.add(inner);
1073
+ }
1074
+ stripped = stripped.replace(parenRegex, " ");
1075
+
1076
+ // Remaining content: comma-separated ids, possibly with stray whitespace.
1077
+ // We split on comma, trim, and keep tokens that look like ref-ids
1078
+ // (uppercase letter prefix). This filters out leftover english words and
1079
+ // is permissive enough to allow custom id schemes (KP-, KT-, KD-, etc.).
1080
+ const parts = stripped.split(",");
1081
+ for (const partRaw of parts) {
1082
+ const part = partRaw.trim();
1083
+ if (part.length === 0) continue;
1084
+ // Take the leading token (whitespace-bounded) so "KT-DEC-0009 garbage"
1085
+ // still yields "KT-DEC-0009".
1086
+ const token = part.split(/\s+/)[0];
1087
+ if (/^[A-Z][A-Z0-9-]+$/.test(token)) {
1088
+ result.cite_ids.push(token);
1089
+ }
1090
+ }
1091
+
1092
+ // Materialise tagSet → array (preserves insertion order via Set semantics).
1093
+ for (const t of tagSet) result.cite_tags.push(t);
1094
+ return result;
1095
+ }
1096
+
1097
+ /**
1098
+ * v2.0.0-rc.20 TASK-03: detect which client surface invoked the hook so the
1099
+ * emitted assistant_turn_observed event can carry a `client` discriminator
1100
+ * without having to inspect the transcript shape.
1101
+ *
1102
+ * Resolution order (first match wins):
1103
+ * 1. `FABRIC_HINT_CLIENT` env var — explicit override, set by the per-
1104
+ * client install pipeline when the hook-config schema supports env
1105
+ * injection.
1106
+ * 2. Path heuristic against `__dirname` — `.claude/` → "cc", `.codex/` →
1107
+ * "codex". Covers the dominant deployment shape (hook script lives
1108
+ * under the client's per-repo dir).
1109
+ *
1110
+ * Returns `undefined` when neither signal fires (e.g. Cursor — deferred to
1111
+ * rc.21 — or a custom deployment). The Zod schema marks `client` optional,
1112
+ * so omitting it leaves the event valid.
1113
+ */
1114
+ function detectClient() {
1115
+ const envClient = process.env.FABRIC_HINT_CLIENT;
1116
+ if (typeof envClient === "string" && envClient.length > 0) {
1117
+ const normalised = envClient.trim().toLowerCase();
1118
+ if (normalised === "cc" || normalised === "codex" || normalised === "cursor") {
1119
+ return normalised;
1120
+ }
1121
+ }
1122
+ // Path heuristic — __dirname is the directory containing this .cjs file
1123
+ // when invoked normally (require.main === module).
1124
+ try {
1125
+ if (__dirname.includes(".claude/") || __dirname.includes(".claude\\")) return "cc";
1126
+ if (__dirname.includes(".codex/") || __dirname.includes(".codex\\")) return "codex";
1127
+ } catch {
1128
+ // __dirname always defined for cjs modules; fall through defensively.
1129
+ }
1130
+ return undefined;
1131
+ }
1132
+
1133
+ /**
1134
+ * v2.0.0-rc.20 TASK-03: emit one `assistant_turn_observed` event per
1135
+ * assistant envelope harvested from the transcript. Wrapped in try/catch
1136
+ * (best-effort, never throws — Stop hook MUST stay non-blocking on any
1137
+ * failure here). The event shape mirrors
1138
+ * assistantTurnObservedEventSchema in
1139
+ * packages/shared/src/schemas/event-ledger.ts (registered in rc.20 TASK-02).
1140
+ *
1141
+ * Call site sits immediately AFTER writeSessionDigestBestEffort so both
1142
+ * digest + per-turn events derive from the same transcript snapshot.
1143
+ *
1144
+ * `id` mirrors the server's convention (`event:<uuid>`) using
1145
+ * crypto.randomUUID when available — falls back to a timestamp+counter
1146
+ * tuple on older Node where randomUUID is missing (cjs hook tooling
1147
+ * defensively targets Node 18+, but the fallback keeps it event-shaped).
1148
+ */
1149
+ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
1150
+ if (stdinPayload === null || typeof stdinPayload !== "object") return;
1151
+ try {
1152
+ const sessionId = stdinPayload.session_id;
1153
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
1154
+ const transcript = summarizeTranscript(stdinPayload.transcript_path);
1155
+ const turns = transcript.assistant_turns;
1156
+ if (!Array.isArray(turns) || turns.length === 0) return;
1157
+
1158
+ // Resolve event-ledger path. Caller already validated cwd shape.
1159
+ const fabricDir = join(cwd, FABRIC_DIR);
1160
+ if (!existsSync(fabricDir)) {
1161
+ // No .fabric/ → workspace is uninitialised. Silently skip; the digest
1162
+ // writer applies the same guard via its own internal check.
1163
+ return;
1164
+ }
1165
+ const ledgerPath = join(fabricDir, EVENT_LEDGER_FILE);
1166
+ const client = detectClient();
1167
+ let randomUUID;
1168
+ try {
1169
+ ({ randomUUID } = require("node:crypto"));
1170
+ } catch {
1171
+ randomUUID = null;
1172
+ }
1173
+
1174
+ for (const turn of turns) {
1175
+ try {
1176
+ const idSuffix = typeof randomUUID === "function"
1177
+ ? randomUUID()
1178
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
1179
+ const event = {
1180
+ kind: "fabric-event",
1181
+ id: `event:${idSuffix}`,
1182
+ ts: Date.now(),
1183
+ schema_version: 1,
1184
+ session_id: sessionId,
1185
+ event_type: EVENT_TYPE_ASSISTANT_TURN_OBSERVED,
1186
+ kb_line_raw: turn.kb_line_raw,
1187
+ cite_ids: Array.isArray(turn.cite_ids) ? turn.cite_ids : [],
1188
+ cite_tags: Array.isArray(turn.cite_tags) ? turn.cite_tags : [],
1189
+ turn_id: `${sessionId}-${turn.envelope_index}`,
1190
+ envelope_index: turn.envelope_index,
1191
+ timestamp: new Date().toISOString(),
1192
+ };
1193
+ if (client !== undefined) event.client = client;
1194
+ appendFileSync(ledgerPath, JSON.stringify(event) + "\n", "utf8");
1195
+ } catch {
1196
+ // Per-turn failure must not abort the remaining turns; the Stop hook
1197
+ // contract is "never block on hook failure". Best-effort continues.
1198
+ }
1199
+ }
1200
+ } catch {
1201
+ // Outer guard — never throw. Hook continues silently.
1202
+ }
1203
+ }
1204
+
969
1205
  /**
970
1206
  * v2.0.0-rc.7 T5: extract user_messages + edit_paths + 1-line title from the
971
1207
  * transcript JSONL referenced by the hook's stdin payload. Best-effort, never
@@ -974,9 +1210,18 @@ function tryReadStdinJson() {
974
1210
  * Claude Code's transcript_path points at a JSONL where each line is a
975
1211
  * message envelope. We sniff for `role: "user"` lines (text content) and
976
1212
  * for tool-use entries naming Edit / Write / MultiEdit to harvest file_path.
1213
+ *
1214
+ * v2.0.0-rc.20 TASK-03: additionally collects `assistant_turns[]` — one
1215
+ * entry per assistant envelope with the parsed KB-line cite metadata. Field
1216
+ * is additive; existing callers (writeSessionDigestBestEffort) ignore it.
977
1217
  */
978
1218
  function summarizeTranscript(transcriptPath) {
979
- const out = { user_messages: [], edit_paths: [], title: "" };
1219
+ // rc.20 TASK-03: additive `assistant_turns` array one entry per assistant
1220
+ // envelope, regardless of whether the first line matched KB:. Downstream
1221
+ // consumers (extractAndWriteAssistantTurnsBestEffort) emit one
1222
+ // assistant_turn_observed event per element; `kb_line_raw=null` when no
1223
+ // KB: line was found.
1224
+ const out = { user_messages: [], edit_paths: [], title: "", assistant_turns: [] };
980
1225
  if (typeof transcriptPath !== "string" || transcriptPath.length === 0) return out;
981
1226
  if (!existsSync(transcriptPath)) return out;
982
1227
  let raw;
@@ -986,6 +1231,7 @@ function summarizeTranscript(transcriptPath) {
986
1231
  return out;
987
1232
  }
988
1233
  const lines = raw.split(/\r?\n/);
1234
+ let envelopeIndex = -1;
989
1235
  for (const line of lines) {
990
1236
  const trimmed = line.trim();
991
1237
  if (trimmed.length === 0) continue;
@@ -996,6 +1242,7 @@ function summarizeTranscript(transcriptPath) {
996
1242
  continue;
997
1243
  }
998
1244
  if (envelope === null || typeof envelope !== "object") continue;
1245
+ envelopeIndex += 1;
999
1246
 
1000
1247
  // User text message — Claude Code shape: { role: "user", content: [...] }
1001
1248
  // OR nested under `message.role`. Be generous.
@@ -1013,6 +1260,61 @@ function summarizeTranscript(transcriptPath) {
1013
1260
  }
1014
1261
  }
1015
1262
 
1263
+ // rc.20 TASK-03: assistant envelope — capture first non-empty line of the
1264
+ // first text block and parse for `KB:` prefix. We push ONE assistant_turns
1265
+ // entry per assistant envelope (even when no KB: line) so downstream can
1266
+ // distinguish "turn observed, no KB" (kb_line_raw=null) from "no turn".
1267
+ if (role === "assistant") {
1268
+ const content = envelope.content || (envelope.message && envelope.message.content);
1269
+ let firstText = null;
1270
+ if (typeof content === "string") {
1271
+ firstText = content;
1272
+ } else if (Array.isArray(content)) {
1273
+ for (const block of content) {
1274
+ if (block && typeof block === "object" && block.type === "text" && typeof block.text === "string") {
1275
+ firstText = block.text;
1276
+ break;
1277
+ }
1278
+ }
1279
+ }
1280
+ let kbLineRaw = null;
1281
+ let citeIds = [];
1282
+ let citeTags = [];
1283
+ if (typeof firstText === "string" && firstText.length > 0) {
1284
+ // First non-empty line.
1285
+ const linesOfText = firstText.split(/\r?\n/);
1286
+ let firstNonEmpty = "";
1287
+ for (const l of linesOfText) {
1288
+ if (l.trim().length > 0) {
1289
+ firstNonEmpty = l.trim();
1290
+ break;
1291
+ }
1292
+ }
1293
+ if (firstNonEmpty.length > 0) {
1294
+ // KB: none (case-insensitive on the literal `none`).
1295
+ const noneMatch = firstNonEmpty.match(/^KB:\s*none\s*$/i);
1296
+ const kbMatch = firstNonEmpty.match(/^KB:\s+(.+)$/);
1297
+ if (noneMatch) {
1298
+ kbLineRaw = firstNonEmpty;
1299
+ const parsed = parseKbLine("none");
1300
+ citeIds = parsed.cite_ids;
1301
+ citeTags = parsed.cite_tags;
1302
+ } else if (kbMatch) {
1303
+ kbLineRaw = firstNonEmpty;
1304
+ const parsed = parseKbLine(kbMatch[1]);
1305
+ citeIds = parsed.cite_ids;
1306
+ citeTags = parsed.cite_tags;
1307
+ }
1308
+ }
1309
+ }
1310
+ out.assistant_turns.push({
1311
+ envelope_index: envelopeIndex,
1312
+ kb_line_raw: kbLineRaw,
1313
+ cite_ids: citeIds,
1314
+ cite_tags: citeTags,
1315
+ });
1316
+ }
1317
+
1016
1318
  // Tool use — look for Edit / Write / MultiEdit and harvest file_path.
1017
1319
  const candidates = [];
1018
1320
  if (envelope.type === "tool_use") candidates.push(envelope);
@@ -1099,6 +1401,13 @@ function main(env, stdio) {
1099
1401
  ? env.stdin_payload
1100
1402
  : tryReadStdinJson();
1101
1403
  writeSessionDigestBestEffort(cwd, stdinPayload);
1404
+ // v2.0.0-rc.20 TASK-03: per-turn cite-policy observation events. Same
1405
+ // best-effort contract as the digest writer — never throws, never blocks
1406
+ // the Stop hook on failure. Shares the transcript snapshot read by
1407
+ // writeSessionDigestBestEffort (each call re-reads independently; the
1408
+ // transcript file is small in practice and re-parse cost is dwarfed by
1409
+ // the hook's other I/O).
1410
+ extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload);
1102
1411
 
1103
1412
  const events = readLedger(cwd);
1104
1413
  let pendingStats;
@@ -1147,6 +1456,19 @@ function main(env, stdio) {
1147
1456
  // rc.7 T7: read the externalized thresholds and pass them into decide.
1148
1457
  // Reader failures degrade silently to documented defaults — fabric-hint
1149
1458
  // must never block on config errors (see hook contract above).
1459
+ //
1460
+ // rc.16 TASK-002 (F2-apply): resolve `fabric_language` ONCE per main()
1461
+ // invocation via the banner-i18n lib. The result threads through
1462
+ // `thresholds.variant` into both decide() and evaluateMaintenanceSignal()
1463
+ // so we read the config file at most once, not five times. Lib reader
1464
+ // is never-throw; defensive try/catch is belt-and-suspenders.
1465
+ let variant = "zh-CN";
1466
+ try {
1467
+ variant = readFabricLanguage(cwd);
1468
+ } catch {
1469
+ variant = "zh-CN";
1470
+ }
1471
+
1150
1472
  let thresholds;
1151
1473
  try {
1152
1474
  thresholds = {
@@ -1155,6 +1477,7 @@ function main(env, stdio) {
1155
1477
  reviewHintPendingAgeDays: readReviewHintPendingAgeDays(cwd),
1156
1478
  maintenanceHintDays: readMaintenanceHintDays(cwd),
1157
1479
  maintenanceHintCooldownDays: readMaintenanceHintCooldownDays(cwd),
1480
+ variant,
1158
1481
  };
1159
1482
  } catch {
1160
1483
  thresholds = {
@@ -1163,6 +1486,7 @@ function main(env, stdio) {
1163
1486
  reviewHintPendingAgeDays: DEFAULT_REVIEW_HINT_PENDING_AGE_DAYS,
1164
1487
  maintenanceHintDays: DEFAULT_MAINTENANCE_HINT_DAYS,
1165
1488
  maintenanceHintCooldownDays: DEFAULT_MAINTENANCE_HINT_COOLDOWN_DAYS,
1489
+ variant,
1166
1490
  };
1167
1491
  }
1168
1492
 
@@ -1293,6 +1617,11 @@ module.exports = {
1293
1617
  readMaintenanceHintCooldownDays,
1294
1618
  readShownCache,
1295
1619
  writeShownCache,
1620
+ // v2.0.0-rc.20 TASK-03 / TASK-09: cite-policy parsing + per-turn emission
1621
+ // helpers (exported for unit testing of the parse + emit contract).
1622
+ parseKbLine,
1623
+ detectClient,
1624
+ extractAndWriteAssistantTurnsBestEffort,
1296
1625
  CONSTANTS: {
1297
1626
  FABRIC_DIR,
1298
1627
  EVENT_LEDGER_FILE,
@@ -54,6 +54,12 @@ const {
54
54
  } = require("node:fs");
55
55
  const { join } = require("node:path");
56
56
 
57
+ // rc.16 TASK-003: shared banner-i18n lib (resolves fabric_language config and
58
+ // renders localized banner text). Mirror of the wiring in fabric-hint.cjs
59
+ // (TASK-002). Variant is resolved ONCE per main() invocation via
60
+ // readFabricLanguage(cwd) and threaded into renderBanner — no fs in render path.
61
+ const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
62
+
57
63
  // -----------------------------------------------------------------------------
58
64
  // rc.12: SessionStart broad-menu is now unconditionally emitted on every
59
65
  // SessionStart fire (matching Skill-style progressive disclosure). Prior
@@ -257,10 +263,12 @@ const MATURITY_PROVEN = "proven";
257
263
  const MATURITY_VERIFIED = "verified";
258
264
  const MATURITY_DRAFT = "draft";
259
265
 
260
- // rc.8 underseed self-check banner text. Single line, mirrors the emoji-prefix
261
- // style of other Fabric banners (cf. fabric-hint.cjs Signal C `📋 Fabric:`).
262
- const IMPORT_RECOMMENDATION_BANNER =
263
- " 📋 Fabric: 知识库稀疏,是否调 /fabric-import git 历史与现有文档回灌知识?";
266
+ // rc.8 underseed self-check banner: single line, emoji-prefixed (cf.
267
+ // fabric-hint.cjs Signal C `📋 Fabric:`). rc.16 TASK-003 routed the literal
268
+ // through the banner-i18n lib (key: 'broadImportBanner') — see main() below
269
+ // for the renderBanner call site. Substring contracts preserved across all
270
+ // variants: leading two-space indent, `📋 Fabric:` prefix, `/fabric-import`
271
+ // verbatim token (asserted by knowledge-hint-broad.test.ts).
264
272
 
265
273
  // -----------------------------------------------------------------------------
266
274
  // CLI invocation
@@ -431,16 +439,29 @@ function renderTruncated(narrow) {
431
439
  *
432
440
  * Returns an array of lines (one stderr write per line keeps the formatter
433
441
  * trivial and testable). Returns [] when there is nothing meaningful to say
434
- * (empty narrow set) so callers know to stay silent.
442
+ * (empty entries set) so callers know to stay silent.
443
+ *
444
+ * Protocol v2 gate (rc.18): payloads must carry `version: 2`. A null/missing
445
+ * payload returns [] silently; a payload with a mismatched `version` returns []
446
+ * after writing exactly one stderr breadcrumb so operators grepping a stuck-
447
+ * banner report can diagnose the version drift without source-diving.
435
448
  */
436
449
  function renderSummary(payload) {
437
- // Local rebind: `payload.narrow` in `--all` mode degenerates to the full
438
- // shared index (every broad-scoped entry), so the field name `narrow` is
439
- // misleading at this rendering layer. We rename the local variable to
440
- // `entries` to avoid name confusion when reading renderSummary in isolation.
441
- // The CLI protocol field name (`payload.narrow`) is unchanged a wire-shape
442
- // rename is a deferred independent task.
443
- const entries = Array.isArray(payload && payload.narrow) ? payload.narrow : [];
450
+ if (!payload || payload.version !== 2) {
451
+ if (payload && payload.version !== undefined) {
452
+ try {
453
+ process.stderr.write(
454
+ `[fabric] hint payload version=${payload.version} unsupported (expected 2), skipping\n`,
455
+ );
456
+ } catch {}
457
+ }
458
+ return [];
459
+ }
460
+ // Protocol v2 (rc.18): the wire field is now `payload.entries`, matching what
461
+ // this renderer always called it locally. The historical `narrow` name (which
462
+ // degenerated in --all mode) has been retired without a compat shim per
463
+ // pre-user clean-slate policy.
464
+ const entries = Array.isArray(payload.entries) ? payload.entries : [];
444
465
  if (entries.length === 0) return [];
445
466
 
446
467
  const truncated = entries.length > TRUNCATION_THRESHOLD;
@@ -455,6 +476,46 @@ function renderSummary(payload) {
455
476
  if (revHash !== null && revHash.length > 0) {
456
477
  lines.push(` revision_hash: ${revHash}`);
457
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
+
458
519
  lines.push(" Use `fab_get_knowledge_sections` to fetch full content.");
459
520
  return lines;
460
521
  }
@@ -487,7 +548,12 @@ function main(env, stdio) {
487
548
  const lines = renderSummary(payload);
488
549
 
489
550
  if (recommendImport) {
490
- lines.push(IMPORT_RECOMMENDATION_BANNER);
551
+ // rc.16 TASK-003: resolve fabric_language ONCE per invocation (only when
552
+ // we actually need to emit the banner — keeps the no-banner path free of
553
+ // the extra config read). 'match-existing' / unknown variant folds to 'en'
554
+ // inside renderBanner per UX i18n Policy class 1.
555
+ const variant = readFabricLanguage(cwd);
556
+ lines.push(renderBanner("broadImportBanner", variant, {}));
491
557
  }
492
558
 
493
559
  if (lines.length === 0) return; // nothing to say — silent exit
@@ -523,7 +589,6 @@ module.exports = {
523
589
  MATURITY_DRAFT,
524
590
  DEFAULT_UNDERSEED_NODE_THRESHOLD,
525
591
  KNOWLEDGE_CANONICAL_TYPES,
526
- IMPORT_RECOMMENDATION_BANNER,
527
592
  },
528
593
  };
529
594