@fenglimg/fabric-cli 2.2.0-rc.8 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-FEOPLBGA.js → chunk-3D7B2UAZ.js} +1 -2
- package/dist/{chunk-YM4XATJF.js → chunk-722JU5BP.js} +2 -0
- package/dist/{chunk-CMDW3PYK.js → chunk-7ZDXBOOU.js} +78 -0
- package/dist/{chunk-JTHWLUD3.js → chunk-E7HJUU34.js} +1 -1
- package/dist/{context-7NUKXDB6.js → context-UJCGYOT6.js} +1 -1
- package/dist/{doctor-REZDNH4A.js → doctor-MDTZWKBK.js} +2 -2
- package/dist/index.js +9 -9
- package/dist/{install-v2-2COC3DO3.js → install-v2-3KJX3YRO.js} +5 -3
- package/dist/{plan-context-hint-G75R4P4J.js → plan-context-hint-5TNGH3R4.js} +1 -1
- package/dist/{uninstall-62F4LNKI.js → uninstall-IFN2KYBK.js} +10 -1
- package/package.json +3 -3
- package/templates/hooks/fabric-hint.cjs +309 -102
- package/templates/hooks/knowledge-hint-broad.cjs +246 -173
- package/templates/hooks/knowledge-hint-narrow.cjs +8 -21
- package/templates/hooks/lib/banner-i18n.cjs +19 -1
- package/templates/hooks/lib/bindings-snapshot-reader.cjs +13 -12
- package/templates/skills/fabric/SKILL.md +15 -9
- package/templates/skills/fabric-archive/SKILL.md +14 -0
- package/templates/skills/fabric-review/SKILL.md +9 -0
- package/templates/hooks/lib/summary-fallback.cjs +0 -273
|
@@ -75,11 +75,9 @@ const {
|
|
|
75
75
|
} = require("node:fs");
|
|
76
76
|
const { dirname, join } = require("node:path");
|
|
77
77
|
|
|
78
|
-
// rc.35
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
// `.fabric/.cache/summary-fallback.json` keyed by revision_hash.
|
|
82
|
-
const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
|
|
78
|
+
// KT-GLD-0006: the rc.35 opaque-summary substitution (resolveOpaqueSummaries) is
|
|
79
|
+
// retired — the write-time mechanical floor in extractKnowledge prevents
|
|
80
|
+
// degenerate summaries at the source, so the narrow hook no longer band-aids them.
|
|
83
81
|
// v2.0.0-rc.37 NEW-17: shared sidecar I/O for the plan-context-hint result
|
|
84
82
|
// cache (skips a redundant CLI cold-start spawn when the same path-set is
|
|
85
83
|
// re-edited within a session and the knowledge graph hasn't changed).
|
|
@@ -1492,21 +1490,10 @@ async function main(env, stdio) {
|
|
|
1492
1490
|
}
|
|
1493
1491
|
|
|
1494
1492
|
const summaryMaxLen = readSummaryMaxLen(cwd);
|
|
1495
|
-
// rc.35
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
|
|
1499
|
-
let resolvedEntries = dedupDecision.filtered;
|
|
1500
|
-
try {
|
|
1501
|
-
resolvedEntries = resolveOpaqueSummaries(
|
|
1502
|
-
dedupDecision.filtered,
|
|
1503
|
-
cwd,
|
|
1504
|
-
currentRevisionHash,
|
|
1505
|
-
);
|
|
1506
|
-
} catch {
|
|
1507
|
-
// resolveOpaqueSummaries swallows its own errors; defensive catch.
|
|
1508
|
-
}
|
|
1509
|
-
const lines = renderSummary({ ...cliPayload, entries: resolvedEntries }, summaryMaxLen);
|
|
1493
|
+
// KT-GLD-0006: the rc.35 opaque-summary runtime substitution is retired — the
|
|
1494
|
+
// write-time mechanical floor in extractKnowledge prevents degenerate summaries
|
|
1495
|
+
// at the source, so the narrow hook renders the description summary as-is.
|
|
1496
|
+
const lines = renderSummary({ ...cliPayload, entries: dedupDecision.filtered }, summaryMaxLen);
|
|
1510
1497
|
if (lines.length === 0) return;
|
|
1511
1498
|
|
|
1512
1499
|
// v2.1.0-rc.1 P4 (F4/S63): store-aware hint — append the write-target store
|
|
@@ -1564,7 +1551,7 @@ async function main(env, stdio) {
|
|
|
1564
1551
|
const surfaceClient = detectClient();
|
|
1565
1552
|
const fabricDir = join(cwd, FABRIC_DIR_REL);
|
|
1566
1553
|
if (surfaceClient !== undefined && existsSync(fabricDir)) {
|
|
1567
|
-
const renderedIds =
|
|
1554
|
+
const renderedIds = dedupDecision.filtered
|
|
1568
1555
|
.map((e) => (e && typeof e.id === "string" ? e.id : null))
|
|
1569
1556
|
.filter((x) => x !== null);
|
|
1570
1557
|
const realSessionId =
|
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
*
|
|
29
29
|
* - STRINGS — exported for test introspection only (read-only by convention).
|
|
30
30
|
*
|
|
31
|
-
* Banner keys
|
|
31
|
+
* Banner keys:
|
|
32
32
|
* Signal A (archive): archiveLine1, archiveActivity, archiveCta
|
|
33
|
+
* Archive backlog: backlogLine1, backlogCta
|
|
33
34
|
* Signal B (review): reviewLine1, reviewCta
|
|
34
35
|
* Signal C (import): importLine1, importCta
|
|
35
36
|
* Signal D (maintenance): maintenanceLine1Never, maintenanceLine1Aged, maintenanceLine2
|
|
@@ -165,6 +166,23 @@ const STRINGS = {
|
|
|
165
166
|
"zh-CN-hybrid": () => " 是否调 /fabric-archive 检查值得归档的决策/踩坑/复用?",
|
|
166
167
|
},
|
|
167
168
|
|
|
169
|
+
// ---- Archive backlog (cross-session safety net, crack 2) ------------------
|
|
170
|
+
// Replaces the old global-24h archive timer: counts DEAD sessions (session
|
|
171
|
+
// ended / idle) carrying unarchived high-value work. Substring "${count}" is
|
|
172
|
+
// addressable for tests. params: { count: number }
|
|
173
|
+
backlogLine1: {
|
|
174
|
+
"zh-CN": (p) => `📋 Fabric: ${p.count} 个已结束的会话有未归档的高价值改动。`,
|
|
175
|
+
en: (p) => `📋 Fabric: ${p.count} ended session(s) carry unarchived high-value work.`,
|
|
176
|
+
"zh-CN-hybrid": (p) => `📋 Fabric: ${p.count} 个已结束的会话有未归档的高价值改动。`,
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
// params: {} — protected token /fabric-archive verbatim across all variants.
|
|
180
|
+
backlogCta: {
|
|
181
|
+
"zh-CN": () => " 是否调 /fabric-archive 跨会话补归档这些遗漏?",
|
|
182
|
+
en: () => " Run /fabric-archive to sweep these missed sessions across the backlog?",
|
|
183
|
+
"zh-CN-hybrid": () => " 是否调 /fabric-archive 跨会话补归档这些遗漏?",
|
|
184
|
+
},
|
|
185
|
+
|
|
168
186
|
// ---- Signal B: review -----------------------------------------------------
|
|
169
187
|
// Source (zh-CN): fabric-hint.cjs:651 `📋 Fabric: 已积累 ${stats.count} 条待审核知识${ageSuffix}。`
|
|
170
188
|
// params: { count, ageSuffix } — ageSuffix is " / 最早一条 N.N 天前" or "" (zh-CN only)
|
|
@@ -148,18 +148,19 @@ function liveKnowledgeStats(snapshot) {
|
|
|
148
148
|
}
|
|
149
149
|
return { pendingCount, canonicalCount, oldestPendingMtimeMs };
|
|
150
150
|
}
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
151
|
+
// #3 (GH issue): snapshot predates knowledge_store_dirs. The cached
|
|
152
|
+
// `knowledge_stats` projection is frozen at snapshot-write time and goes stale
|
|
153
|
+
// out-of-band (store grew via git pull / cross-workspace sync), so trusting it
|
|
154
|
+
// re-introduced exactly the false-nudge this whole field cures — observed a
|
|
155
|
+
// store with 61 live canonical entries whose cached count was frozen at 1,
|
|
156
|
+
// mis-firing the "knowledge sparse → /fabric-import" underseed nudge AND
|
|
157
|
+
// defeating the fabric-import `canonical > 50 → SKIP` guard. read_set carries
|
|
158
|
+
// no resolved store root either (alias/uuid only), so a live recount is
|
|
159
|
+
// impossible without re-resolution (which hooks must not do). Return null
|
|
160
|
+
// ("undeterminable") so callers SKIP the nudge rather than act on a stale
|
|
161
|
+
// count — old snapshots self-heal on the next install/sync/store-op (which
|
|
162
|
+
// regenerates the snapshot WITH knowledge_store_dirs). 宁可不弹也别误弹
|
|
163
|
+
// (KT-DEC-0007: hook = nudge, never a false-positive gate).
|
|
163
164
|
return null;
|
|
164
165
|
}
|
|
165
166
|
|
|
@@ -17,15 +17,21 @@ description: Fabric 入口层路由 — 参考 maestro 的顺序协调方式,
|
|
|
17
17
|
|
|
18
18
|
## Intent Map
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
<!-- fabric:router-intent:begin -->
|
|
21
|
+
<!-- 本块由 `fabric install` 从 7 个 leaf skill 的 description Triggers 子句生成。严禁手编;改 leaf description 后重跑 `fabric install`。 -->
|
|
22
|
+
|
|
23
|
+
| 用户意图(leaf description Triggers) | 下游 skill |
|
|
21
24
|
| --- | --- |
|
|
22
|
-
|
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
28
|
-
|
|
|
25
|
+
| 以后/always/never/下次/记一下;wrong-turn-revert;decision-confirm;dismissal-reason;/fabric-archive | `fabric-archive` |
|
|
26
|
+
| 审批/驳回/复审/重审/approve/reject/review pending | `fabric-review` |
|
|
27
|
+
| 导入历史/bootstrap fabric/mine changelog/挖掘 commit | `fabric-import` |
|
|
28
|
+
| 同步知识库/sync stores/fabric-sync/解决 store 冲突/rebase 冲突 | `fabric-sync` |
|
|
29
|
+
| 创建 store/挂载 store/绑定知识库/store 列表/切换写库/set up knowledge store | `fabric-store` |
|
|
30
|
+
| 审计知识库/清理陈旧知识/知识库体检/deprecate 条目/prune stale knowledge/知识库瘦身/淘汰旧决策 | `fabric-audit` |
|
|
31
|
+
| 连接知识/找关联条目/建知识图谱/link related entries/补 related 边/知识库连通性 | `fabric-connect` |
|
|
32
|
+
|
|
33
|
+
`S_CLASSIFY` 的 `task_type` 枚举:`archive | review | import | sync | store | audit | connect`
|
|
34
|
+
<!-- fabric:router-intent:end -->
|
|
29
35
|
|
|
30
36
|
## State Machine
|
|
31
37
|
|
|
@@ -35,7 +41,7 @@ description: Fabric 入口层路由 — 参考 maestro 的顺序协调方式,
|
|
|
35
41
|
|
|
36
42
|
```json
|
|
37
43
|
{
|
|
38
|
-
"task_type": "
|
|
44
|
+
"task_type": "<Intent Map task_type 枚举之一>",
|
|
39
45
|
"scope": "project|store|entry|paths|null",
|
|
40
46
|
"write_intent": true,
|
|
41
47
|
"confidence": "high|medium|low"
|
|
@@ -65,6 +65,18 @@ The deterministic ledger scan now runs **server-side** — call `fab_archive_sca
|
|
|
65
65
|
|
|
66
66
|
Then (LLM side, Boundary B): for each returned `session_id`, load `.fabric/.cache/session-digests/<session_id>.md`, concatenate into a `### Cross-session digest` block, and populate `source_sessions[]` + `session_context` for Phase 4. Cap at `archive_digest_max_sessions`. Missing digest files degrade silently.
|
|
67
67
|
|
|
68
|
+
**Coverage transparency (crack 3 — cheap recall backstop).** BEFORE collecting candidates, surface the scan's watermark + drops to the user so a human can act as the recall detector and manually override (`--range <session_id>` to force a dropped session back in). This is the affordable substitute for the (deferred) periodic cold-eval miss-rate audit — show, don't hide, what the deterministic filter skipped:
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
📋 归档覆盖到 <covered_through_ts 转人类可读时间>。
|
|
72
|
+
纳入会话: <session_ids.length> 个。
|
|
73
|
+
跳过 <dropped.length> 个: <每个 {session_id 短码} (reason)>
|
|
74
|
+
reason 含义: user_dismissed=用户曾拒绝 / cooldown=12h 防抖内 / no_new_signal=自上次归档无新高价值活
|
|
75
|
+
若某个被跳过的会话其实有该归档的内容,显式 `fabric-archive --range <session_id>` 强制纳入。
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Render `dropped` only when non-empty; render the watermark line always. en variant mirrors the same fields. Keep it ONE compact block — this is a backstop affordance, not a report.
|
|
79
|
+
|
|
68
80
|
`Read ref/phase-1-cross-session.md` for the filter state machine + digest-stitch + graceful-degradation notes. The hand-rolled `tail -n 200` scan is retired — `fab_archive_scan` is the source of truth.
|
|
69
81
|
|
|
70
82
|
Graceful degradation: missing digest cache → single-session fallback. Missing `session_archive_attempted` events (pre-rc.25) → legacy "scan everything since anchor" behaviour.
|
|
@@ -93,6 +105,8 @@ Pre-PASS HARD gate (rc.37 NEW-4): per candidate, run `fab_review action="search"
|
|
|
93
105
|
|
|
94
106
|
For each candidate, propose **type** ∈ {model, decision, guideline, pitfall, process}, **layer** ∈ {team, personal} via the verbatim heuristic below, **slug** (kebab-case 2-5 words, 20-40 chars, unique within type+layer bucket), **summary** (1-2 sentences).
|
|
95
107
|
|
|
108
|
+
> **Self-sufficiency standard — guideline / model summaries (KT-GLD-0001/0006).** These two types land in the SessionStart **ALWAYS-ACTIVE** sink as a single INDEX line with NO body injected — so the summary IS the operative rule the agent acts on. Author it as a self-contained imperative that states the thesis (the *what* + the operative *so-what*), e.g. `改源码前先读 bootstrap+compiler config;scripts 为 init 主执行边界`. A topic label that only points at the body (`Code style guidelines`, `Scope model`) is NOT acceptable here — the reader can't act on it without a fetch, breaking the always-active contract. decision/pitfall/process summaries are exempt (they surface as `must_read_if` triggers, deliberately pointers). Do NOT self-judge sufficiency in this phase (curse-of-knowledge rubber-stamps — KT-GLD-0006); authoring to the standard is the write-time floor, the zero-context cold-eval at review time is the real gate.
|
|
109
|
+
|
|
96
110
|
#### Layer Classification Heuristic (verbatim, contract-locked)
|
|
97
111
|
|
|
98
112
|
> - **强 team**: 引用本项目代码、团队共识用语("we decided")、fabric-import 路径产物、业务领域、绑定本项目代码的 pitfall
|
|
@@ -119,6 +119,15 @@ DO NOT AskUserQuestion "is this a duplicate?" — LLM already judged. User only
|
|
|
119
119
|
|
|
120
120
|
`Read ref/semantic-check.md` for full procedure + 三类判断的细化定义.
|
|
121
121
|
|
|
122
|
+
## Summary Self-Sufficiency Gate (guideline / model only — KT-GLD-0006)
|
|
123
|
+
|
|
124
|
+
Guideline/model entries surface in the SessionStart **ALWAYS-ACTIVE** sink as a body-less INDEX line, so an opaque summary (`Code style guidelines`) leaks in as an unactionable "rule". Before approving/promoting a **guideline or model** (only these two types — decision/pitfall/process surface as `must_read_if` triggers and are exempt), run the summary through the **zero-context cold-eval judge**, never your own judgment:
|
|
125
|
+
|
|
126
|
+
- The reviewing agent self-judging sufficiency is curse-of-knowledge — it back-fills from context it already has and rubber-stamps pointers (KT-GLD-0006). The withheld-body cold eval is the whole point.
|
|
127
|
+
- Build the batch with `summary-cold-eval.ts#buildColdEvalBatch` (rubric = `COLD_EVAL_RUBRIC`, candidates = the guideline/model summaries) and hand it to an **offline** judge via `maestro delegate` (zero-context, batched — NOT on the hot path). The judge returns `ColdEvalVerdict[]`.
|
|
128
|
+
- For each `self_sufficient=false` verdict: surface `⚠ Summary not act-on-able (cold-eval); suggested: <suggested_summary>` and route to `modify-content` (summary rewrite, stable_id preserved) — do NOT approve as-is. `self_sufficient=true` → no action.
|
|
129
|
+
- This is a nudge, not a hard block (KT-DEC-0007): the user may still approve over a failed verdict, but the flag must be shown.
|
|
130
|
+
|
|
122
131
|
## Narrowing Imported Entries & Modify Sub-Flow
|
|
123
132
|
|
|
124
133
|
`modify` is the only action that mutates frontmatter or stable_id. Two paths:
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* rc.35 TASK-06 (P0-10.b) — summary-fallback library.
|
|
3
|
-
*
|
|
4
|
-
* Resolves opaque hint entries (where `entry.summary === entry.id` so the
|
|
5
|
-
* AI sees no information beyond the id) by reading the entry's markdown file
|
|
6
|
-
* from mounted store `knowledge/<type>/<id>--<slug>.md`, extracting the first
|
|
7
|
-
* paragraph under `## Summary`, and substituting that text into the entry
|
|
8
|
-
* before the hook renders it.
|
|
9
|
-
*
|
|
10
|
-
* Caching: results are stored in `.fabric/.cache/summary-fallback.json`
|
|
11
|
-
* keyed by the current `revision_hash` returned by plan-context-hint. The
|
|
12
|
-
* cache is wiped wholesale when the revision changes (cheap invariant —
|
|
13
|
-
* any meta rev bump implies entry text MAY have moved). Per-process call
|
|
14
|
-
* also benefits from in-memory dedup since the same opaque id may appear
|
|
15
|
-
* across narrow + broad paths.
|
|
16
|
-
*
|
|
17
|
-
* Design contract:
|
|
18
|
-
* - Never throw. ANY failure (cache read, fs scan, file read) degrades
|
|
19
|
-
* to a no-op — the original opaque summary is left untouched. Hooks
|
|
20
|
-
* must remain best-effort.
|
|
21
|
-
* - Idempotent over identical inputs. Two calls in succession with the
|
|
22
|
-
* same revision_hash + entries set produce zero disk reads on the
|
|
23
|
-
* second call.
|
|
24
|
-
*
|
|
25
|
-
* Public API (module.exports):
|
|
26
|
-
* resolveOpaqueSummaries(entries, projectRoot, revisionHash) — returns
|
|
27
|
-
* a NEW array of entries with `summary` substituted for opaque cases.
|
|
28
|
-
* Original `entry.id` is preserved verbatim.
|
|
29
|
-
*
|
|
30
|
-
* _extractFirstSummaryParagraph(md) — pure helper, exposed for testing.
|
|
31
|
-
*
|
|
32
|
-
* _readCache / _writeCache — exposed for testing.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
const { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } = require("node:fs");
|
|
36
|
-
const { homedir } = require("node:os");
|
|
37
|
-
const { join } = require("node:path");
|
|
38
|
-
|
|
39
|
-
const CACHE_DIR_REL = ".fabric/.cache";
|
|
40
|
-
const CACHE_FILE_REL = ".fabric/.cache/summary-fallback.json";
|
|
41
|
-
const GLOBAL_CONFIG_FILE = "fabric-global.json";
|
|
42
|
-
const PROJECT_CONFIG_REL = ".fabric/fabric-config.json";
|
|
43
|
-
const SUMMARY_MAX_LEN = 80;
|
|
44
|
-
const KNOWLEDGE_TYPE_DIRS = ["decisions", "pitfalls", "guidelines", "models", "processes"];
|
|
45
|
-
|
|
46
|
-
function _isOpaque(entry) {
|
|
47
|
-
if (!entry || typeof entry.id !== "string" || typeof entry.summary !== "string") {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
return entry.summary.trim() === entry.id.trim();
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Pure helper: extract the first paragraph under a `## Summary` heading.
|
|
55
|
-
*
|
|
56
|
-
* - `## Summary` is case-insensitive but level-sensitive (only H2).
|
|
57
|
-
* - First paragraph = lines until blank line or next heading.
|
|
58
|
-
* - Collapses whitespace + trims; returns `""` if no summary section or
|
|
59
|
-
* the section is empty.
|
|
60
|
-
*/
|
|
61
|
-
function _extractFirstSummaryParagraph(md) {
|
|
62
|
-
if (typeof md !== "string" || md.length === 0) return "";
|
|
63
|
-
const lines = md.split(/\r?\n/);
|
|
64
|
-
let i = 0;
|
|
65
|
-
while (i < lines.length) {
|
|
66
|
-
if (/^##\s+summary\s*$/i.test(lines[i].trim())) {
|
|
67
|
-
i += 1;
|
|
68
|
-
break;
|
|
69
|
-
}
|
|
70
|
-
i += 1;
|
|
71
|
-
}
|
|
72
|
-
if (i >= lines.length) return "";
|
|
73
|
-
// Skip blank lines after the heading
|
|
74
|
-
while (i < lines.length && lines[i].trim().length === 0) i += 1;
|
|
75
|
-
// Collect until the next blank line or next heading
|
|
76
|
-
const buf = [];
|
|
77
|
-
while (i < lines.length) {
|
|
78
|
-
const line = lines[i];
|
|
79
|
-
if (line.trim().length === 0) break;
|
|
80
|
-
if (/^#{1,6}\s/.test(line.trim())) break;
|
|
81
|
-
buf.push(line.trim());
|
|
82
|
-
i += 1;
|
|
83
|
-
}
|
|
84
|
-
const flat = buf.join(" ").replace(/\s+/g, " ").trim();
|
|
85
|
-
if (flat.length === 0) return "";
|
|
86
|
-
if (flat.length <= SUMMARY_MAX_LEN) return flat;
|
|
87
|
-
return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function _readCache(projectRoot) {
|
|
91
|
-
const cachePath = join(projectRoot, CACHE_FILE_REL);
|
|
92
|
-
if (!existsSync(cachePath)) return null;
|
|
93
|
-
try {
|
|
94
|
-
const raw = readFileSync(cachePath, "utf8");
|
|
95
|
-
const parsed = JSON.parse(raw);
|
|
96
|
-
if (parsed && typeof parsed === "object" && typeof parsed.revision === "string" && parsed.summaries && typeof parsed.summaries === "object") {
|
|
97
|
-
return parsed;
|
|
98
|
-
}
|
|
99
|
-
} catch {
|
|
100
|
-
// ignore — caller treats null as no-cache
|
|
101
|
-
}
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function _writeCache(projectRoot, payload) {
|
|
106
|
-
try {
|
|
107
|
-
const cacheDir = join(projectRoot, CACHE_DIR_REL);
|
|
108
|
-
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
109
|
-
const cachePath = join(projectRoot, CACHE_FILE_REL);
|
|
110
|
-
writeFileSync(cachePath, JSON.stringify(payload), "utf8");
|
|
111
|
-
} catch {
|
|
112
|
-
// Best-effort — failing to persist cache is not an error
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Return mounted store directories in the project's read-set
|
|
118
|
-
* (`required_stores` plus implicit personal). This hook helper is deliberately
|
|
119
|
-
* tiny and best-effort: malformed config degrades to an empty read-set rather
|
|
120
|
-
* than throwing during a shell hook.
|
|
121
|
-
*/
|
|
122
|
-
function _readJson(path) {
|
|
123
|
-
try {
|
|
124
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
125
|
-
} catch {
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function _globalRoot() {
|
|
131
|
-
return join(process.env.FABRIC_HOME || homedir(), ".fabric");
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function _storeDir(globalRoot, store) {
|
|
135
|
-
return join(globalRoot, "stores", store.mount_name || store.store_uuid);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function _readSetStoreDirs(projectRoot) {
|
|
139
|
-
const globalRoot = _globalRoot();
|
|
140
|
-
const global = _readJson(join(globalRoot, GLOBAL_CONFIG_FILE));
|
|
141
|
-
if (!global || !Array.isArray(global.stores)) return [];
|
|
142
|
-
const project = _readJson(join(projectRoot, PROJECT_CONFIG_REL)) || {};
|
|
143
|
-
const required = Array.isArray(project.required_stores) ? project.required_stores : [];
|
|
144
|
-
const stores = [];
|
|
145
|
-
|
|
146
|
-
for (const req of required) {
|
|
147
|
-
if (!req || typeof req.id !== "string") continue;
|
|
148
|
-
const matched = global.stores.find(
|
|
149
|
-
(store) => store && !store.personal && (store.alias === req.id || store.store_uuid === req.id),
|
|
150
|
-
);
|
|
151
|
-
if (matched) stores.push(matched);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const personal = global.stores.find((store) => store && store.personal);
|
|
155
|
-
if (personal) stores.push(personal);
|
|
156
|
-
|
|
157
|
-
return stores.map((store) => ({
|
|
158
|
-
alias: typeof store.alias === "string" ? store.alias : "",
|
|
159
|
-
dir: _storeDir(globalRoot, store),
|
|
160
|
-
}));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function _splitQualifiedId(id) {
|
|
164
|
-
const idx = typeof id === "string" ? id.indexOf(":") : -1;
|
|
165
|
-
if (idx <= 0) return { alias: "", stableId: id };
|
|
166
|
-
return { alias: id.slice(0, idx), stableId: id.slice(idx + 1) };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Scan mounted store `knowledge/<type>/` for the canonical `<id>--<slug>.md`
|
|
171
|
-
* matching `stableId`. Tries the most likely type-dir first based on the
|
|
172
|
-
* entry's `type` hint, then falls back to scanning all canonical type
|
|
173
|
-
* directories. Returns the absolute path or null.
|
|
174
|
-
*
|
|
175
|
-
* The id→file mapping is unique by construction (stable_id is allocated
|
|
176
|
-
* once per file), so the first match wins.
|
|
177
|
-
*/
|
|
178
|
-
function _findEntryFile(projectRoot, stableId, typeHint) {
|
|
179
|
-
const parsedId = _splitQualifiedId(stableId);
|
|
180
|
-
const storeDirs = _readSetStoreDirs(projectRoot).filter(
|
|
181
|
-
(store) => parsedId.alias.length === 0 || store.alias === parsedId.alias,
|
|
182
|
-
);
|
|
183
|
-
if (storeDirs.length === 0) return null;
|
|
184
|
-
const tryOrder = [];
|
|
185
|
-
if (typeof typeHint === "string" && typeHint.length > 0) {
|
|
186
|
-
// Accept both singular and plural hints — find the plural form.
|
|
187
|
-
const lower = typeHint.toLowerCase();
|
|
188
|
-
const plural = KNOWLEDGE_TYPE_DIRS.find((d) => d === lower || d.startsWith(lower));
|
|
189
|
-
if (plural) tryOrder.push(plural);
|
|
190
|
-
}
|
|
191
|
-
for (const t of KNOWLEDGE_TYPE_DIRS) {
|
|
192
|
-
if (!tryOrder.includes(t)) tryOrder.push(t);
|
|
193
|
-
}
|
|
194
|
-
const prefix = `${parsedId.stableId}--`;
|
|
195
|
-
for (const store of storeDirs) {
|
|
196
|
-
const baseDir = join(store.dir, "knowledge");
|
|
197
|
-
if (!existsSync(baseDir)) continue;
|
|
198
|
-
for (const t of tryOrder) {
|
|
199
|
-
const typeDir = join(baseDir, t);
|
|
200
|
-
if (!existsSync(typeDir)) continue;
|
|
201
|
-
let files;
|
|
202
|
-
try {
|
|
203
|
-
files = readdirSync(typeDir);
|
|
204
|
-
} catch {
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
for (const f of files) {
|
|
208
|
-
if (f.startsWith(prefix) && f.endsWith(".md")) {
|
|
209
|
-
return join(typeDir, f);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function _resolveOne(projectRoot, entry) {
|
|
218
|
-
const filePath = _findEntryFile(projectRoot, entry.id, entry.type);
|
|
219
|
-
if (filePath === null) return "";
|
|
220
|
-
let md;
|
|
221
|
-
try {
|
|
222
|
-
md = readFileSync(filePath, "utf8");
|
|
223
|
-
} catch {
|
|
224
|
-
return "";
|
|
225
|
-
}
|
|
226
|
-
return _extractFirstSummaryParagraph(md);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Main API. Returns a new array of entries with `summary` swapped for
|
|
231
|
-
* the extracted fallback wherever the original summary was opaque AND
|
|
232
|
-
* the fallback extraction yielded a non-empty string. Non-opaque entries
|
|
233
|
-
* pass through unchanged.
|
|
234
|
-
*/
|
|
235
|
-
function resolveOpaqueSummaries(entries, projectRoot, revisionHash) {
|
|
236
|
-
if (!Array.isArray(entries) || entries.length === 0) return entries;
|
|
237
|
-
const cache = _readCache(projectRoot);
|
|
238
|
-
const cachedSummaries = cache && cache.revision === revisionHash && cache.summaries ? cache.summaries : {};
|
|
239
|
-
const nextCacheSummaries = { ...cachedSummaries };
|
|
240
|
-
let cacheChanged = cache === null || cache.revision !== revisionHash;
|
|
241
|
-
const result = entries.map((entry) => {
|
|
242
|
-
if (!_isOpaque(entry)) return entry;
|
|
243
|
-
const id = entry.id;
|
|
244
|
-
let fallback;
|
|
245
|
-
if (Object.prototype.hasOwnProperty.call(cachedSummaries, id)) {
|
|
246
|
-
fallback = cachedSummaries[id];
|
|
247
|
-
} else {
|
|
248
|
-
fallback = _resolveOne(projectRoot, entry);
|
|
249
|
-
nextCacheSummaries[id] = fallback;
|
|
250
|
-
cacheChanged = true;
|
|
251
|
-
}
|
|
252
|
-
if (typeof fallback === "string" && fallback.length > 0) {
|
|
253
|
-
return { ...entry, summary: fallback };
|
|
254
|
-
}
|
|
255
|
-
return entry;
|
|
256
|
-
});
|
|
257
|
-
if (cacheChanged) {
|
|
258
|
-
_writeCache(projectRoot, { revision: revisionHash, summaries: nextCacheSummaries });
|
|
259
|
-
}
|
|
260
|
-
return result;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
module.exports = {
|
|
264
|
-
resolveOpaqueSummaries,
|
|
265
|
-
_extractFirstSummaryParagraph,
|
|
266
|
-
_readCache,
|
|
267
|
-
_writeCache,
|
|
268
|
-
_findEntryFile,
|
|
269
|
-
_readSetStoreDirs,
|
|
270
|
-
_isOpaque,
|
|
271
|
-
SUMMARY_MAX_LEN,
|
|
272
|
-
KNOWLEDGE_TYPE_DIRS,
|
|
273
|
-
};
|