@fenglimg/fabric-cli 2.0.0 → 2.0.1

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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +6 -5
  3. package/dist/chunk-BATF4PEJ.js +361 -0
  4. package/dist/{chunk-OBQU6NHO.js → chunk-COI5VDFU.js} +0 -18
  5. package/dist/chunk-D25XJ4BC.js +880 -0
  6. package/dist/chunk-MF3OTILQ.js +544 -0
  7. package/dist/chunk-PWLW3B57.js +18 -0
  8. package/dist/config-XJIPZNUP.js +13 -0
  9. package/dist/doctor-EJDSEJSS.js +810 -0
  10. package/dist/index.js +15 -8
  11. package/dist/{init-BIRSIOXO.js → install-EKWMFLUU.js} +622 -711
  12. package/dist/metrics-ACEQFPDU.js +122 -0
  13. package/dist/onboard-coverage-MFCAEBDO.js +220 -0
  14. package/dist/{plan-context-hint-QMUPAXIB.js → plan-context-hint-FC6P3WFE.js} +34 -28
  15. package/dist/uninstall-MH7ZIB6M.js +1064 -0
  16. package/package.json +30 -5
  17. package/templates/hooks/cite-policy-evict.cjs +231 -0
  18. package/templates/hooks/configs/README.md +29 -6
  19. package/templates/hooks/configs/claude-code.json +14 -3
  20. package/templates/hooks/configs/codex-hooks.json +6 -3
  21. package/templates/hooks/configs/cursor-hooks.json +8 -10
  22. package/templates/hooks/fabric-hint.cjs +833 -105
  23. package/templates/hooks/knowledge-hint-broad.cjs +509 -135
  24. package/templates/hooks/knowledge-hint-narrow.cjs +791 -26
  25. package/templates/hooks/lib/banner-i18n.cjs +309 -0
  26. package/templates/hooks/lib/cite-contract-reminder.cjs +173 -0
  27. package/templates/hooks/lib/cite-line-parser.cjs +158 -0
  28. package/templates/hooks/lib/client-adapter.cjs +106 -0
  29. package/templates/hooks/lib/config-cache.cjs +107 -0
  30. package/templates/hooks/lib/state-store.cjs +84 -0
  31. package/templates/hooks/lib/summary-fallback.cjs +210 -0
  32. package/templates/skills/fabric-archive/SKILL.md +93 -419
  33. package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
  34. package/templates/skills/fabric-archive/ref/e5-cron-recap.md +58 -0
  35. package/templates/skills/fabric-archive/ref/i18n-policy.md +86 -0
  36. package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
  37. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +218 -0
  38. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +62 -0
  39. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +68 -0
  40. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +108 -0
  41. package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
  42. package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
  43. package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
  44. package/templates/skills/fabric-archive/ref/rc-history.md +38 -0
  45. package/templates/skills/fabric-archive/ref/worked-examples.md +78 -0
  46. package/templates/skills/fabric-import/SKILL.md +75 -516
  47. package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
  48. package/templates/skills/fabric-import/ref/i18n-policy.md +79 -0
  49. package/templates/skills/fabric-import/ref/output-contract.md +61 -0
  50. package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
  51. package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
  52. package/templates/skills/fabric-import/ref/state-recovery.md +57 -0
  53. package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
  54. package/templates/skills/fabric-review/SKILL.md +86 -284
  55. package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
  56. package/templates/skills/fabric-review/ref/i18n-policy.md +111 -0
  57. package/templates/skills/fabric-review/ref/modify-flow.md +103 -0
  58. package/templates/skills/fabric-review/ref/output-contract.md +58 -0
  59. package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
  60. package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
  61. package/templates/skills/fabric-review/ref/worked-examples.md +95 -0
  62. package/templates/skills/lib/shared-policy.md +69 -0
  63. package/dist/chunk-6ICJICVU.js +0 -10
  64. package/dist/chunk-74SZWYPH.js +0 -658
  65. package/dist/chunk-EYIDD2YS.js +0 -1000
  66. package/dist/doctor-T7JWODKG.js +0 -282
  67. package/dist/hooks-Y74Y5LQS.js +0 -12
  68. package/dist/scan-LMK3UCWL.js +0 -22
  69. package/dist/serve-H554BHLG.js +0 -124
  70. package/templates/agents-md/AGENTS.md.template +0 -59
  71. package/templates/bootstrap/CLAUDE.md +0 -8
  72. package/templates/bootstrap/codex-AGENTS-header.md +0 -6
  73. package/templates/bootstrap/cursor-fabric-bootstrap.mdc +0 -10
@@ -0,0 +1,309 @@
1
+ /**
2
+ * v2.0.0-rc.16 TASK-001 (F2-prep): shared banner-i18n library for hook scripts.
3
+ *
4
+ * Provides:
5
+ * - readFabricLanguage(projectRoot) → 'zh-CN' | 'en' | 'zh-CN-hybrid' | 'match-existing'
6
+ * Synchronously reads `fabric_language` from `.fabric/fabric-config.json`.
7
+ * Mirrors the never-throw contract of the existing config readers in
8
+ * fabric-hint.cjs (readReviewHintPendingCount, readMaintenanceHintDays, etc.):
9
+ * missing file / parse error / missing field → returns 'zh-CN' (the
10
+ * documented BACKWARD-COMPAT default — preserves rc.15 hard-coded zh-CN
11
+ * hook output when fabric_language was never a configurable key).
12
+ *
13
+ * 'match-existing' is ONLY returned when explicitly set in config; per UX
14
+ * i18n Policy class 1, renderBanner then folds 'match-existing' (and any
15
+ * unknown variant) down to 'en'.
16
+ *
17
+ * RC.16 TASK-002 NOTE: this default was originally 'match-existing' in
18
+ * TASK-001 but was tightened to 'zh-CN' here so that the existing rc.7+
19
+ * fabric-hint test fixtures (which never set fabric_language and expect
20
+ * zh-CN substrings byte-identically) continue passing without modification.
21
+ * Pre-user clean-slate policy: no shim, but back-compat for in-tree tests
22
+ * is the right line — they encode the rc.15 user-visible contract.
23
+ *
24
+ * - renderBanner(key, variant, params) → string
25
+ * Renders one of the 11 banner fragments for the requested variant.
26
+ * Variant fallback: STRINGS[key][variant] ?? STRINGS[key]['en'].
27
+ * Unknown / 'match-existing' / missing → 'en' table.
28
+ *
29
+ * - STRINGS — exported for test introspection only (read-only by convention).
30
+ *
31
+ * Banner keys (11 total):
32
+ * Signal A (archive): archiveLine1, archiveActivity, archiveCta
33
+ * Signal B (review): reviewLine1, reviewCta
34
+ * Signal C (import): importLine1, importCta
35
+ * Signal D (maintenance): maintenanceLine1Never, maintenanceLine1Aged, maintenanceLine2
36
+ * Broad hook: broadImportBanner
37
+ *
38
+ * Protected tokens — NEVER translated, kept verbatim across all 4 variants:
39
+ * - Slash commands: /fabric-archive, /fabric-review, /fabric-import
40
+ * - CLI commands: `fabric doctor --lint`
41
+ * - Numeric / template substrings the existing tests assert on:
42
+ * "${hoursElapsed.toFixed(1)}h" (e.g. "25.0h"), "阈值 ${N}h",
43
+ * "${count} 条", "${nodeCount}/${threshold}", "${days} 天"
44
+ * - 📋 Fabric: emoji prefix
45
+ *
46
+ * zh-CN-hybrid policy: Chinese narrative prose with English protected tokens
47
+ * preserved verbatim. In practice this matches zh-CN exactly because the
48
+ * banners already inline slash commands + CLI commands without translation;
49
+ * we keep the variant entries explicit anyway for forward-compat (future copy
50
+ * may diverge, e.g. mixing English connector words).
51
+ *
52
+ * match-existing policy: per UX i18n Policy class 1, falls back to 'en' at
53
+ * render time. The fallback decision is centralized in renderBanner; only an
54
+ * EXPLICIT `fabric_language: "match-existing"` in config triggers the en
55
+ * fallback. Unset / missing config defaults to 'zh-CN' (rc.15 back-compat).
56
+ *
57
+ * Pattern reference:
58
+ * - Never-throw fs read: fabric-hint.cjs `_readConfigNumber`,
59
+ * `readReviewHintPendingCount` (lines 720-743)
60
+ * - hooks/lib/*.cjs precedent: session-digest-writer.cjs
61
+ */
62
+ "use strict";
63
+
64
+ const { existsSync, readFileSync } = require("node:fs");
65
+ const { join } = require("node:path");
66
+
67
+ const FABRIC_DIR = ".fabric";
68
+ const CONFIG_FILE = "fabric-config.json";
69
+
70
+ const VALID_LANGUAGES = ["zh-CN", "en", "zh-CN-hybrid", "match-existing"];
71
+ // rc.16 TASK-002: backward-compat default. rc.15 and earlier hardcoded
72
+ // zh-CN in the hook scripts; preserving zh-CN as the unset-default keeps
73
+ // the rc.7+ fabric-hint test fixtures (which assert Chinese substrings
74
+ // without ever setting fabric_language) green and matches the user-visible
75
+ // contract real workspaces have observed since rc.7.
76
+ const DEFAULT_LANGUAGE = "zh-CN";
77
+ const RENDER_FALLBACK_VARIANT = "en";
78
+
79
+ /**
80
+ * Read `fabric_language` from <projectRoot>/.fabric/fabric-config.json.
81
+ *
82
+ * Returns one of the four valid language codes. Missing file, malformed JSON,
83
+ * missing/unknown field value → DEFAULT_LANGUAGE ('zh-CN' — see comment on
84
+ * the constant for the back-compat rationale). NEVER throws — config-read
85
+ * failure must not block any hook.
86
+ */
87
+ function readFabricLanguage(projectRoot) {
88
+ if (typeof projectRoot !== "string" || projectRoot.length === 0) {
89
+ return DEFAULT_LANGUAGE;
90
+ }
91
+ const configPath = join(projectRoot, FABRIC_DIR, CONFIG_FILE);
92
+ if (!existsSync(configPath)) return DEFAULT_LANGUAGE;
93
+ try {
94
+ const parsed = JSON.parse(readFileSync(configPath, "utf8"));
95
+ const v = parsed && parsed.fabric_language;
96
+ if (typeof v === "string" && VALID_LANGUAGES.indexOf(v) !== -1) {
97
+ return v;
98
+ }
99
+ } catch {
100
+ // fall through to default
101
+ }
102
+ return DEFAULT_LANGUAGE;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // String table
107
+ // ---------------------------------------------------------------------------
108
+ //
109
+ // Each key maps variant -> (params) => string. Templates intentionally use
110
+ // the same parameter names across all four variants so call sites pass one
111
+ // shape. Substring contracts (see file header) are preserved verbatim across
112
+ // translations; only narrative connector words shift.
113
+ // ---------------------------------------------------------------------------
114
+
115
+ const STRINGS = {
116
+ // ---- Signal A: archive ----------------------------------------------------
117
+ // Source (zh-CN): fabric-hint.cjs:614 `📋 Fabric: 距上次归档 ${parts}。`
118
+ // params: { parts } where parts is pre-joined `已过 25.0h(阈值 24h)` etc.
119
+ //
120
+ // v2.0.0-rc.27 TASK-005 (audit §2.17): `parts` is now constructed by the
121
+ // sibling archivePartsHours / archivePartsEdits keys (also per-variant) so
122
+ // the caller never hardcodes Chinese into the en banner. The substring
123
+ // contract on "25.0h" / "阈值 N" / "次编辑" is preserved per-variant but
124
+ // each variant gets a coherent monolingual rendering — pre-rc.27 produced
125
+ // mixed-language output like `📋 Fabric: 已过 25.0h since last archive.`
126
+ // (audit §2.17 reproduction).
127
+ archiveLine1: {
128
+ "zh-CN": (p) => `📋 Fabric: 距上次归档 ${p.parts}。`,
129
+ en: (p) => `📋 Fabric: ${p.parts} since last archive.`,
130
+ "zh-CN-hybrid": (p) => `📋 Fabric: 距上次归档 ${p.parts}。`,
131
+ },
132
+
133
+ // v2.0.0-rc.27 TASK-005 (audit §2.17): per-variant assembly of the
134
+ // hours-trigger fragment. zh-CN tightens to the original substring
135
+ // contract (`已过 25.0h(阈值 24h)`); en variant translates the prose
136
+ // while preserving the numeric tokens; hybrid mirrors zh-CN.
137
+ // params: { hoursFixed: string (already toFixed(1)), threshold: number }
138
+ archivePartsHours: {
139
+ "zh-CN": (p) => `已过 ${p.hoursFixed}h(阈值 ${p.threshold}h)`,
140
+ en: (p) => `${p.hoursFixed}h elapsed (threshold ${p.threshold}h)`,
141
+ "zh-CN-hybrid": (p) => `已过 ${p.hoursFixed}h(阈值 ${p.threshold}h)`,
142
+ },
143
+
144
+ // v2.0.0-rc.27 TASK-005 (audit §2.17): edits-trigger fragment.
145
+ // params: { count: number, threshold: number }
146
+ archivePartsEdits: {
147
+ "zh-CN": (p) => `累计 ${p.count} 次编辑(阈值 ${p.threshold})`,
148
+ en: (p) => `${p.count} edits since last archive (threshold ${p.threshold})`,
149
+ "zh-CN-hybrid": (p) => `累计 ${p.count} 次编辑(阈值 ${p.threshold})`,
150
+ },
151
+
152
+ // Source (zh-CN): fabric-hint.cjs:619 ` 最近活动集中在: ${activity}。`
153
+ // params: { activity }
154
+ archiveActivity: {
155
+ "zh-CN": (p) => ` 最近活动集中在: ${p.activity}。`,
156
+ en: (p) => ` Recent activity centered on: ${p.activity}.`,
157
+ "zh-CN-hybrid": (p) => ` 最近活动集中在: ${p.activity}。`,
158
+ },
159
+
160
+ // Source (zh-CN): fabric-hint.cjs:621 ` 是否调 /fabric-archive 检查值得归档的决策/踩坑/复用?`
161
+ // params: {} — protected token /fabric-archive verbatim across all variants.
162
+ archiveCta: {
163
+ "zh-CN": () => " 是否调 /fabric-archive 检查值得归档的决策/踩坑/复用?",
164
+ en: () => " Run /fabric-archive to review decisions/pitfalls/reusables worth archiving?",
165
+ "zh-CN-hybrid": () => " 是否调 /fabric-archive 检查值得归档的决策/踩坑/复用?",
166
+ },
167
+
168
+ // ---- Signal B: review -----------------------------------------------------
169
+ // Source (zh-CN): fabric-hint.cjs:651 `📋 Fabric: 已积累 ${stats.count} 条待审核知识${ageSuffix}。`
170
+ // params: { count, ageSuffix } — ageSuffix is " / 最早一条 N.N 天前" or "" (zh-CN only)
171
+ // For en variant we shape the suffix inline to keep substring "${count}" addressable.
172
+ reviewLine1: {
173
+ "zh-CN": (p) => `📋 Fabric: 已积累 ${p.count} 条待审核知识${p.ageSuffix || ""}。`,
174
+ en: (p) => {
175
+ const suffix =
176
+ p.ageSuffix && p.ageSuffix.length > 0
177
+ ? p.ageSuffix
178
+ .replace(" / 最早一条 ", " / oldest is ")
179
+ .replace(" 天前", "d old")
180
+ : "";
181
+ return `📋 Fabric: ${p.count} pending knowledge entries accumulated${suffix}.`;
182
+ },
183
+ "zh-CN-hybrid": (p) => `📋 Fabric: 已积累 ${p.count} 条待审核知识${p.ageSuffix || ""}。`,
184
+ },
185
+
186
+ // Source (zh-CN): fabric-hint.cjs:652 ` 是否调 /fabric-review 审核 pending/ 条目?`
187
+ // params: {} — protected token /fabric-review verbatim across all variants.
188
+ reviewCta: {
189
+ "zh-CN": () => " 是否调 /fabric-review 审核 pending/ 条目?",
190
+ en: () => " Run /fabric-review to triage pending/ entries?",
191
+ "zh-CN-hybrid": () => " 是否调 /fabric-review 审核 pending/ 条目?",
192
+ },
193
+
194
+ // ---- Signal C: import (underseed) ----------------------------------------
195
+ // Source (zh-CN): fabric-hint.cjs:697 `📋 Fabric: 知识库节点数 ${nodeCount}/${threshold},距 init_scan_completed ${hoursSinceInit.toFixed(1)}h。`
196
+ // params: { nodeCount, threshold, hoursSinceInit } — caller supplies hoursSinceInit
197
+ // already toFixed(1)'d (i.e. as string "24.5") to keep all rendering pure.
198
+ importLine1: {
199
+ "zh-CN": (p) =>
200
+ `📋 Fabric: 知识库节点数 ${p.nodeCount}/${p.threshold},距 init_scan_completed ${p.hoursSinceInit}h。`,
201
+ en: (p) =>
202
+ `📋 Fabric: knowledge node count ${p.nodeCount}/${p.threshold}, ${p.hoursSinceInit}h since init_scan_completed.`,
203
+ "zh-CN-hybrid": (p) =>
204
+ `📋 Fabric: 知识库节点数 ${p.nodeCount}/${p.threshold},距 init_scan_completed ${p.hoursSinceInit}h。`,
205
+ },
206
+
207
+ // Source (zh-CN): fabric-hint.cjs:698 ` 是否调 /fabric-import 从 git 历史与现有文档回灌知识?`
208
+ // params: {} — protected token /fabric-import verbatim across all variants.
209
+ importCta: {
210
+ "zh-CN": () => " 是否调 /fabric-import 从 git 历史与现有文档回灌知识?",
211
+ en: () => " Run /fabric-import to backfill knowledge from git history and existing docs?",
212
+ "zh-CN-hybrid": () => " 是否调 /fabric-import 从 git 历史与现有文档回灌知识?",
213
+ },
214
+
215
+ // ---- Signal D: maintenance -----------------------------------------------
216
+ // Source (zh-CN): fabric-hint.cjs:931 `📋 Fabric: 从未运行 lint 检查。`
217
+ // params: {} — substring "从未运行 lint 检查" is test-asserted (zh-CN test).
218
+ maintenanceLine1Never: {
219
+ "zh-CN": () => "📋 Fabric: 从未运行 lint 检查。",
220
+ en: () => "📋 Fabric: lint check has never been run.",
221
+ "zh-CN-hybrid": () => "📋 Fabric: 从未运行 lint 检查。",
222
+ },
223
+
224
+ // Source (zh-CN): fabric-hint.cjs:932 `📋 Fabric: 已 ${days} 天未跑 lint 检查(实际 ${ageDays.toFixed(1)}d)。`
225
+ // params: { days, ageDays } — ageDays caller-supplied as already-toFixed(1) string.
226
+ // Substring "已 N 天未跑 lint" is test-asserted (zh-CN test).
227
+ maintenanceLine1Aged: {
228
+ "zh-CN": (p) => `📋 Fabric: 已 ${p.days} 天未跑 lint 检查(实际 ${p.ageDays}d)。`,
229
+ en: (p) => `📋 Fabric: ${p.days} days since the last lint check (actual ${p.ageDays}d).`,
230
+ "zh-CN-hybrid": (p) => `📋 Fabric: 已 ${p.days} 天未跑 lint 检查(实际 ${p.ageDays}d)。`,
231
+ },
232
+
233
+ // Source (zh-CN): fabric-hint.cjs:929 ` 是否调 \`fabric doctor --lint\` 看看知识库健康度?`
234
+ // params: {} — protected token `fabric doctor --lint` (with backticks) verbatim.
235
+ maintenanceLine2: {
236
+ "zh-CN": () => " 是否调 `fabric doctor --lint` 看看知识库健康度?",
237
+ en: () => " Run `fabric doctor --lint` to check knowledge-base health?",
238
+ "zh-CN-hybrid": () => " 是否调 `fabric doctor --lint` 看看知识库健康度?",
239
+ },
240
+
241
+ // ---- Broad hook: import recommendation ------------------------------------
242
+ // Source (zh-CN): knowledge-hint-broad.cjs:262
243
+ // " 📋 Fabric: 知识库稀疏,是否调 /fabric-import 从 git 历史与现有文档回灌知识?"
244
+ // Note: leading two spaces are intentional (existing banner indent).
245
+ // params: {} — protected token /fabric-import verbatim.
246
+ broadImportBanner: {
247
+ "zh-CN": () => " 📋 Fabric: 知识库稀疏,是否调 /fabric-import 从 git 历史与现有文档回灌知识?",
248
+ en: () =>
249
+ " 📋 Fabric: knowledge base is sparse — run /fabric-import to backfill from git history and existing docs?",
250
+ "zh-CN-hybrid": () =>
251
+ " 📋 Fabric: 知识库稀疏,是否调 /fabric-import 从 git 历史与现有文档回灌知识?",
252
+ },
253
+
254
+ // ---- Broad hook: meta auto-refresh breadcrumb (rc.22 Scope D T-D4) -------
255
+ // Surfaced ONLY when planContext() detected meta drift and rebuilt the meta
256
+ // in-place (server emits `auto_healed: true` in plan-context-hint payload).
257
+ // Single informational line — operators need a breadcrumb when meta auto-
258
+ // heals so a "why did revision change?" question has a paper trail.
259
+ //
260
+ // Two render shapes:
261
+ // - metaAutoRefreshedBanner: full transition with prev → cur 8-char hash
262
+ // prefixes. Used when both previous_revision_hash + revision_hash present.
263
+ // - metaAutoRefreshedBannerGeneric: defensive fallback when the server
264
+ // emitted `auto_healed: true` but did not include previous_revision_hash
265
+ // (T10 noted this edge case). No hash transition shown.
266
+ //
267
+ // Note: 🔄 emoji prefix is intentional (matches the project's general "no
268
+ // emoji" rule's exception for explicit user request — see TASK-011 description).
269
+ // params: { prev, cur } — both already 8-char hex strings, caller-supplied.
270
+ metaAutoRefreshedBanner: {
271
+ "zh-CN": (p) => ` 🔄 Fabric: 元数据已自动刷新(sha ${p.prev} → ${p.cur})`,
272
+ en: (p) => ` 🔄 Fabric: meta auto-refreshed (sha ${p.prev} → ${p.cur})`,
273
+ "zh-CN-hybrid": (p) => ` 🔄 Fabric: 元数据已自动刷新(sha ${p.prev} → ${p.cur})`,
274
+ },
275
+
276
+ // Generic variant — no hash transition. Used when auto_healed:true but
277
+ // previous_revision_hash is missing from the payload.
278
+ metaAutoRefreshedBannerGeneric: {
279
+ "zh-CN": () => " 🔄 Fabric: 元数据已自动刷新",
280
+ en: () => " 🔄 Fabric: meta auto-refreshed",
281
+ "zh-CN-hybrid": () => " 🔄 Fabric: 元数据已自动刷新",
282
+ },
283
+ };
284
+
285
+ /**
286
+ * Render a banner fragment for the requested variant.
287
+ *
288
+ * Variant resolution:
289
+ * 1. If STRINGS[key][variant] exists → use it.
290
+ * 2. Else fall back to STRINGS[key][RENDER_FALLBACK_VARIANT] ('en').
291
+ * 3. If key itself is unknown → returns "" (defensive; never throws).
292
+ *
293
+ * 'match-existing' is intentionally NOT in the STRINGS table so it folds
294
+ * down to the 'en' fallback per UX i18n Policy class 1.
295
+ */
296
+ function renderBanner(key, variant, params) {
297
+ const entry = STRINGS[key];
298
+ if (!entry) return "";
299
+ const tmpl = entry[variant] || entry[RENDER_FALLBACK_VARIANT];
300
+ if (typeof tmpl !== "function") return "";
301
+ try {
302
+ return tmpl(params || {});
303
+ } catch {
304
+ // Defensive: a missing param shouldn't crash the hook.
305
+ return "";
306
+ }
307
+ }
308
+
309
+ module.exports = { readFabricLanguage, renderBanner, STRINGS };
@@ -0,0 +1,173 @@
1
+ // v2.0.0-rc.24 TASK-05: L1 Stop hook soft reminder for missing cite contract.
2
+ //
3
+ // Reads `.fabric/agents.meta.json` to build a stable_id → knowledge_type lookup
4
+ // map, then scans summarised assistant turns (cite_ids + cite_tags +
5
+ // cite_commitments parallel arrays produced by lib/cite-line-parser.cjs) for
6
+ // turns that cited a decision-class or pitfall-class id with [recalled] tag
7
+ // but no operator commitment and no skip:<reason>.
8
+ //
9
+ // Emits one reminder line per offending id (deduplicated across the turn
10
+ // summary). Non-blocking — caller writes the lines to stderr; failure to
11
+ // load the meta file or absence of offenders means zero output.
12
+ //
13
+ // Reminder template (rc.24 lock B2 / L1 enforcement layer):
14
+ // ⚠ KB: <id> cited as [recalled] but missing contract; add → edit:<glob>
15
+ // or → skip:<reason> next turn
16
+ //
17
+ // Type filter rationale: only `decision` and `pitfall` types are contract-
18
+ // required per rc.24 design lock B6 (idTypeMap routing). `model`,
19
+ // `guideline`, `process` use reference-cite or LLM-judge (deferred to rc.25+)
20
+ // and are intentionally skipped here to avoid false-positive nudges.
21
+ //
22
+ // agents.meta.json schema note: `description.knowledge_type` values are
23
+ // SINGULAR (`decision`, `pitfall`, `model`, `guideline`, `process`) per
24
+ // packages/shared/src/schemas/agents-meta.ts. The reminder filter normalises
25
+ // any plural input defensively but the canonical contract is singular.
26
+ //
27
+ // Reading happens once per hook invocation (caller passes the projectRoot;
28
+ // the lib does the fs read internally). The map is small (<200 entries in
29
+ // typical corpora) so caching beyond the per-invocation scope is unnecessary.
30
+
31
+ const { existsSync, readFileSync } = require("node:fs");
32
+ const { join } = require("node:path");
33
+
34
+ const FABRIC_DIR = ".fabric";
35
+ const AGENTS_META_FILE = "agents.meta.json";
36
+
37
+ // Knowledge types that require contract commitments on [recalled] cites.
38
+ // Matches the singular form persisted by `withDerivedAgentsMetaNodeDefaults`
39
+ // in packages/shared/src/schemas/agents-meta.ts. We accept both singular
40
+ // and plural defensively so a future schema change to plurals doesn't
41
+ // silently break the filter.
42
+ const CONTRACT_REQUIRED_TYPES = new Set([
43
+ "decision",
44
+ "decisions",
45
+ "pitfall",
46
+ "pitfalls",
47
+ ]);
48
+
49
+ /**
50
+ * Build a Map<stable_id, knowledge_type> from <projectRoot>/.fabric/agents.meta.json.
51
+ *
52
+ * Never throws — missing file, malformed JSON, missing nodes key, etc. all
53
+ * yield an empty Map. The caller's downstream filter then becomes a no-op
54
+ * (no id resolves → no reminders).
55
+ *
56
+ * @param {string} projectRoot - workspace root
57
+ * @returns {Map<string, string>} stable_id → knowledge_type (singular)
58
+ */
59
+ function readKnowledgeTypeMap(projectRoot) {
60
+ const out = new Map();
61
+ if (typeof projectRoot !== "string" || projectRoot.length === 0) return out;
62
+
63
+ const metaPath = join(projectRoot, FABRIC_DIR, AGENTS_META_FILE);
64
+ if (!existsSync(metaPath)) return out;
65
+
66
+ let raw;
67
+ try {
68
+ raw = readFileSync(metaPath, "utf8");
69
+ } catch {
70
+ return out;
71
+ }
72
+
73
+ let parsed;
74
+ try {
75
+ parsed = JSON.parse(raw);
76
+ } catch {
77
+ return out;
78
+ }
79
+
80
+ if (parsed === null || typeof parsed !== "object") return out;
81
+ const nodes = parsed.nodes;
82
+ if (nodes === null || typeof nodes !== "object") return out;
83
+
84
+ for (const [id, node] of Object.entries(nodes)) {
85
+ if (node === null || typeof node !== "object") continue;
86
+ const description = node.description;
87
+ if (description === null || typeof description !== "object") continue;
88
+ const kt = description.knowledge_type;
89
+ if (typeof kt !== "string" || kt.length === 0) continue;
90
+ out.set(id, kt);
91
+ }
92
+
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Scan parsed assistant turns for cites that should have a contract but
98
+ * don't, returning the reminder lines to emit.
99
+ *
100
+ * Filter (all must hold for a given index i within a turn):
101
+ * 1. cite_tags includes "recalled" (turn-level — applies to the cited id)
102
+ * 2. cite_commitments[i].operators is empty AND cite_commitments[i].skip_reason is null
103
+ * 3. idTypeMap.get(cite_ids[i]) is in {decision, pitfall}
104
+ *
105
+ * Tag-level filter clarification: rc.20 cite_tags is parallel to ALL parsed
106
+ * lines (including sentinels), but for the contract-missing reminder we use
107
+ * the turn-level semantic — if the assistant tagged the cite as [recalled],
108
+ * the operator-or-skip contract applies. Per TASK-04 invariant, cite_ids and
109
+ * cite_commitments are parallel index-aligned arrays (length-N each).
110
+ *
111
+ * Sentinel turns (cite_ids=[], cite_tags=["none"]) contribute no offenders
112
+ * because the cite_ids loop has zero iterations.
113
+ *
114
+ * Offenders are deduplicated by id across the entire turn array; multiple
115
+ * turns citing the same id yield ONE reminder line.
116
+ *
117
+ * @param {Object} args
118
+ * @param {Array<{cite_ids: string[], cite_tags: string[], cite_commitments: Array<{operators: Array<unknown>, skip_reason: string|null}>}>} args.assistant_turns
119
+ * @param {Map<string, string>} args.idTypeMap
120
+ * @returns {string[]} reminder lines (empty when no offenders)
121
+ */
122
+ function formatContractMissingReminders({ assistant_turns, idTypeMap }) {
123
+ if (!Array.isArray(assistant_turns) || assistant_turns.length === 0) return [];
124
+ if (!(idTypeMap instanceof Map) || idTypeMap.size === 0) return [];
125
+
126
+ const offenders = new Set();
127
+
128
+ for (const turn of assistant_turns) {
129
+ if (turn === null || typeof turn !== "object") continue;
130
+ const citeIds = Array.isArray(turn.cite_ids) ? turn.cite_ids : [];
131
+ const citeTags = Array.isArray(turn.cite_tags) ? turn.cite_tags : [];
132
+ const commitments = Array.isArray(turn.cite_commitments) ? turn.cite_commitments : [];
133
+
134
+ // Turn-level: the [recalled] tag must appear in the turn's tag set.
135
+ if (!citeTags.includes("recalled")) continue;
136
+
137
+ // Iterate by cite_ids.length — sentinel entries don't have ids so they
138
+ // contribute zero iterations even if cite_tags carries "none".
139
+ for (let i = 0; i < citeIds.length; i += 1) {
140
+ const id = citeIds[i];
141
+ if (typeof id !== "string" || id.length === 0) continue;
142
+
143
+ const type = idTypeMap.get(id);
144
+ if (!CONTRACT_REQUIRED_TYPES.has(type)) continue;
145
+
146
+ const commitment = commitments[i];
147
+ if (commitment === null || typeof commitment !== "object") continue;
148
+ const operators = Array.isArray(commitment.operators) ? commitment.operators : [];
149
+ const skipReason = commitment.skip_reason;
150
+ const hasContract = operators.length > 0 || (typeof skipReason === "string" && skipReason.length > 0);
151
+ if (hasContract) continue;
152
+
153
+ offenders.add(id);
154
+ }
155
+ }
156
+
157
+ if (offenders.size === 0) return [];
158
+
159
+ // Stable order: insertion order is the order ids first appeared across turns.
160
+ const reminders = [];
161
+ for (const id of offenders) {
162
+ reminders.push(
163
+ `⚠ KB: ${id} cited as [recalled] but missing contract; add \`→ edit:<glob>\` or \`→ skip:<reason>\` next turn`,
164
+ );
165
+ }
166
+ return reminders;
167
+ }
168
+
169
+ module.exports = {
170
+ readKnowledgeTypeMap,
171
+ formatContractMissingReminders,
172
+ CONTRACT_REQUIRED_TYPES,
173
+ };
@@ -0,0 +1,158 @@
1
+ // v2.0.0-rc.24 TASK-04: CJS twin of packages/shared/src/cite-line-parser.ts.
2
+ //
3
+ // Hook runtime has NO node_modules access, so the shared TS module cannot be
4
+ // imported. This file is a hand-authored CJS mirror; behavioral parity is
5
+ // asserted by packages/cli/__tests__/cite-line-parser-parity.test.ts which
6
+ // runs both implementations against the same corpus and asserts identical
7
+ // output. Any drift between this file and ../../shared/src/cite-line-parser.ts
8
+ // MUST be reflected in BOTH files plus the parity-test corpus, otherwise the
9
+ // parity test fails and blocks the commit.
10
+ //
11
+ // Why a hand-authored twin (not transpile-at-install or string-template inject)?
12
+ // - tsup/esbuild are CLI build-time deps, NOT install-time deps; bundling
13
+ // them into the install pipeline grows the user-facing footprint.
14
+ // - The parser is small (≤150 LOC), pure (zero deps), and rarely changes —
15
+ // hand-syncing is cheaper than introducing transpile machinery.
16
+ // - The existing `installHookLibs` pipeline auto-copies every `.cjs` under
17
+ // templates/hooks/lib/ to each client's hooks/lib/ dir, so this file
18
+ // auto-ships to cc/codex/cursor with no install pipeline change.
19
+ //
20
+ // Vocabulary contract (mirrored 1:1 with the TS source):
21
+ // - cite_tags enum: planned | recalled | chained-from | dismissed | none
22
+ // - operator kinds: edit | not_edit | require | forbid
23
+ // (source token `!edit:` → schema kind `not_edit`)
24
+ // - skip:<reason> captures everything after the first colon, so
25
+ // `skip:other:non-codifiable` yields skip_reason="other:non-codifiable".
26
+ // - Index contract: cite_commitments[i] ↔ cite_ids[i]. Sentinel `KB: none`
27
+ // contributes a "none" cite_tag only — no id, no commitment.
28
+
29
+ const ID_RE = /^K[TP]-[A-Z]+-\d+$/;
30
+ const SENTINEL_RE = /^KB:\s*none\b\s*(?:\[[^\]]*\])?\s*$/i;
31
+ // v2.0.0-rc.27 TASK-003 (audit §2.18): multi-id citations supported via
32
+ // comma-separated ID group. Mirrors packages/shared/src/cite-line-parser.ts.
33
+ const FULL_RE =
34
+ /^KB:\s+(K[TP]-[A-Z]+-\d+(?:\s*,\s*K[TP]-[A-Z]+-\d+)*)(?:\s+\(([^)]*)\))?(?:\s+\[([^\]]+)\])?(?:\s+→\s*(.+))?\s*$/;
35
+ const CHAINED_FROM_ID_RE = /chained-from\s+(K[TP]-[A-Z]+-\d+)/i;
36
+
37
+ const ALLOWED_TAGS = new Set([
38
+ // v2.0.0-rc.37 NEW-1: new simplified 2-state tag set ([applied] / [dismissed]).
39
+ // Old 4-state tags (planned / recalled / chained-from) accepted for
40
+ // backward compat — they continue to parse and count toward cite-coverage
41
+ // so in-flight workspaces don't lose their existing audit signal.
42
+ "applied",
43
+ "dismissed",
44
+ // Legacy tags (rc ≤36).
45
+ "planned",
46
+ "recalled",
47
+ "chained-from",
48
+ "none",
49
+ ]);
50
+
51
+ function parseTag(rawTag) {
52
+ if (!rawTag) return "none";
53
+ // Tags may carry tails like `chained-from KT-DEC-0001` or
54
+ // `dismissed:scope-mismatch`; head token (whitespace/colon-bounded) wins.
55
+ const head = rawTag.trim().split(/[\s:]+/)[0].toLowerCase();
56
+ return ALLOWED_TAGS.has(head) ? head : "none";
57
+ }
58
+
59
+ function parseContractTail(tail) {
60
+ const result = { operators: [], skip_reason: null };
61
+ if (!tail) return result;
62
+ const tokens = tail.trim().split(/\s+/).filter((t) => t.length > 0);
63
+ for (const token of tokens) {
64
+ // skip:<reason> — reason may itself contain a colon (skip:other:<text>).
65
+ const skipMatch = token.match(/^skip:(.+)$/i);
66
+ if (skipMatch) {
67
+ if (result.skip_reason === null) result.skip_reason = skipMatch[1];
68
+ continue;
69
+ }
70
+ // !edit:<target> → schema kind "not_edit".
71
+ const notEditMatch = token.match(/^!edit:(.+)$/i);
72
+ if (notEditMatch) {
73
+ result.operators.push({ kind: "not_edit", target: notEditMatch[1] });
74
+ continue;
75
+ }
76
+ const opMatch = token.match(/^(edit|require|forbid):(.+)$/i);
77
+ if (opMatch) {
78
+ result.operators.push({
79
+ kind: opMatch[1].toLowerCase(),
80
+ target: opMatch[2],
81
+ });
82
+ }
83
+ // Unknown token → forward-compat drop.
84
+ }
85
+ return result;
86
+ }
87
+
88
+ function parseLine(line) {
89
+ const trimmed = line.trim();
90
+ if (trimmed.length === 0) return null;
91
+ if (SENTINEL_RE.test(trimmed)) {
92
+ return { ids: [], tag: "none", commitment: null };
93
+ }
94
+ const fullMatch = trimmed.match(FULL_RE);
95
+ if (fullMatch) {
96
+ // v2.0.0-rc.27 TASK-003 (audit §2.18): split + revalidate each id;
97
+ // capture chained-from tail id when present.
98
+ const primaryIds = fullMatch[1]
99
+ .split(",")
100
+ .map((part) => part.trim())
101
+ .filter((part) => part.length > 0);
102
+ if (primaryIds.some((id) => !ID_RE.test(id))) return null;
103
+
104
+ const rawTag = fullMatch[3];
105
+ const tag = parseTag(rawTag);
106
+
107
+ const chainedIds = [];
108
+ if (rawTag) {
109
+ const chained = CHAINED_FROM_ID_RE.exec(rawTag);
110
+ if (chained && ID_RE.test(chained[1])) {
111
+ chainedIds.push(chained[1]);
112
+ }
113
+ }
114
+
115
+ return {
116
+ ids: primaryIds.concat(chainedIds),
117
+ tag,
118
+ commitment: parseContractTail(fullMatch[4]),
119
+ };
120
+ }
121
+ return null;
122
+ }
123
+
124
+ /**
125
+ * Parse one or more newline-separated `KB:` cite lines into structured arrays
126
+ * matching the assistant_turn_observed event-ledger fields. Tolerates
127
+ * whitespace, CR/LF, blank lines, interleaved prose. Never throws.
128
+ *
129
+ * v2.0.0-rc.27 TASK-003 (audit §2.18): supports multi-id citations
130
+ * (`KB: KT-DEC-0001, KT-PIT-0005 ...`) and surfaces `chained-from <id>`'s
131
+ * embedded id as an additional cite_id. cite_tags carries one tag per LINE.
132
+ */
133
+ function parseCiteLine(raw) {
134
+ const result = { cite_ids: [], cite_tags: [], cite_commitments: [] };
135
+ if (typeof raw !== "string") return result;
136
+ for (const line of raw.split(/\r?\n/)) {
137
+ const parsed = parseLine(line);
138
+ if (!parsed) continue;
139
+ result.cite_tags.push(parsed.tag);
140
+ for (const id of parsed.ids) {
141
+ result.cite_ids.push(id);
142
+ }
143
+ if (parsed.commitment !== null) {
144
+ // v2.0.0-rc.27.1 (Codex review fix): cite_commitments MUST be index-
145
+ // aligned with cite_ids per the schema doc on event-ledger.ts:428.
146
+ // Multi-id citations share ONE parsed contract — propagate it across
147
+ // every id slot so downstream consumers (`doctor.ts` per-cite walk +
148
+ // `cite-contract-reminder.cjs`) can look up `commitments[i]` for any
149
+ // valid `i < cite_ids.length` without falling into an undefined slot.
150
+ for (let i = 0; i < parsed.ids.length; i += 1) {
151
+ result.cite_commitments.push(parsed.commitment);
152
+ }
153
+ }
154
+ }
155
+ return result;
156
+ }
157
+
158
+ module.exports = { parseCiteLine };