@fenglimg/fabric-cli 2.0.0-rc.1 → 2.0.0-rc.10
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/README.md +6 -6
- package/dist/chunk-6ICJICVU.js +10 -0
- package/dist/chunk-AW3G7ZH5.js +576 -0
- package/dist/chunk-HQLEHH4O.js +321 -0
- package/dist/{chunk-UHNP7T7W.js → chunk-MT3R57VG.js} +346 -86
- package/dist/{chunk-5LOYBXWD.js → chunk-OBQU6NHO.js} +2 -52
- package/dist/chunk-WPTA74BY.js +184 -0
- package/dist/chunk-WWNXR34K.js +49 -0
- package/dist/doctor-RILCO5OG.js +282 -0
- package/dist/hooks-NX32PPEN.js +13 -0
- package/dist/index.js +8 -5
- package/dist/{init-DRHUYHYA.js → init-SAVH4SKE.js} +188 -491
- package/dist/plan-context-hint-QMUPAXIB.js +98 -0
- package/dist/{scan-HU2EGITF.js → scan-ELSNCSKS.js} +4 -2
- package/dist/{serve-3LXXSBFR.js → serve-NGLXHDYC.js} +8 -4
- package/dist/uninstall-DBAR2JBS.js +1082 -0
- package/package.json +3 -3
- package/templates/bootstrap/CLAUDE.md +1 -1
- package/templates/bootstrap/codex-AGENTS-header.md +1 -1
- package/templates/bootstrap/cursor-fabric-bootstrap.mdc +1 -1
- package/templates/hooks/configs/README.md +73 -0
- package/templates/hooks/configs/claude-code.json +37 -0
- package/templates/hooks/configs/codex-hooks.json +20 -0
- package/templates/hooks/configs/cursor-hooks.json +20 -0
- package/templates/hooks/fabric-hint.cjs +1337 -0
- package/templates/hooks/knowledge-hint-broad.cjs +612 -0
- package/templates/hooks/knowledge-hint-narrow.cjs +826 -0
- package/templates/hooks/lib/session-digest-writer.cjs +172 -0
- package/templates/skills/fabric-archive/SKILL.md +486 -0
- package/templates/skills/fabric-import/SKILL.md +560 -0
- package/templates/skills/fabric-review/SKILL.md +382 -0
- package/dist/doctor-DUHWLAYD.js +0 -98
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* rc.6 TASK-019 (E1) — SessionStart broad-injection hook.
|
|
4
|
+
*
|
|
5
|
+
* Stateless ambient-awareness hook: on every SessionStart event, invokes
|
|
6
|
+
* `fabric plan-context-hint --all` to fetch the workspace's broad-scoped
|
|
7
|
+
* knowledge index, then renders a human-readable summary to stderr so the
|
|
8
|
+
* Agent's session opens with passive awareness of what knowledge exists.
|
|
9
|
+
*
|
|
10
|
+
* No state file. No fingerprint dedup. No cooldown. SessionStart fires once
|
|
11
|
+
* per session boot — the rendering cost is paid exactly that often. The
|
|
12
|
+
* narrow-injection sibling (E2, knowledge-hint-narrow.cjs) handles
|
|
13
|
+
* per-Edit/Write hints with a session-hints cache.
|
|
14
|
+
*
|
|
15
|
+
* Output contract (stderr only):
|
|
16
|
+
*
|
|
17
|
+
* When narrow count <= 30 (full per-type listing mode):
|
|
18
|
+
* [fabric] Session start — N broad-scoped knowledge entries available:
|
|
19
|
+
* [decision] (proven)
|
|
20
|
+
* - <id> · <summary>
|
|
21
|
+
* [pitfall] (verified)
|
|
22
|
+
* - <id> · <summary>
|
|
23
|
+
* ...
|
|
24
|
+
* revision_hash: <hash>
|
|
25
|
+
* Use `fab_get_knowledge_sections` to fetch full content.
|
|
26
|
+
*
|
|
27
|
+
* When narrow count > 30 (grouped-truncation mode, per type):
|
|
28
|
+
* [fabric] Session start — N broad-scoped knowledge entries available (truncated):
|
|
29
|
+
* [decision] proven (3):
|
|
30
|
+
* - <id> · <summary>
|
|
31
|
+
* - ...
|
|
32
|
+
* [decision] verified (12): <id1>, <id2>, ...
|
|
33
|
+
* [decision] draft: 7 entries
|
|
34
|
+
* ...
|
|
35
|
+
* revision_hash: <hash>
|
|
36
|
+
* Use `fab_get_knowledge_sections` to fetch full content.
|
|
37
|
+
*
|
|
38
|
+
* When 0 entries / CLI unavailable / CLI error / parse failure:
|
|
39
|
+
* (no output — silent exit 0)
|
|
40
|
+
*
|
|
41
|
+
* Stdout is intentionally empty: Stop hooks may pollute stdout to signal
|
|
42
|
+
* `decision:block`, but SessionStart is informational, never blocking.
|
|
43
|
+
*
|
|
44
|
+
* Failure invariant: any error path (spawn failure, ENOENT, timeout,
|
|
45
|
+
* JSON.parse throw) MUST end in silent exit 0. The hook never blocks
|
|
46
|
+
* session start on its own malfunction.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const { spawnSync } = require("node:child_process");
|
|
50
|
+
const {
|
|
51
|
+
existsSync,
|
|
52
|
+
mkdirSync,
|
|
53
|
+
readdirSync,
|
|
54
|
+
readFileSync,
|
|
55
|
+
writeFileSync,
|
|
56
|
+
} = require("node:fs");
|
|
57
|
+
const { dirname, join } = require("node:path");
|
|
58
|
+
|
|
59
|
+
// -----------------------------------------------------------------------------
|
|
60
|
+
// rc.7 T8: SessionStart revision_hash gating.
|
|
61
|
+
//
|
|
62
|
+
// Q-14 problem: every SessionStart re-dumped the full broad knowledge list,
|
|
63
|
+
// causing banner blindness. Solution: hash-of-canonical-graph gating — record
|
|
64
|
+
// the last-emitted `payload.revision_hash` to a sidecar; on subsequent
|
|
65
|
+
// SessionStart fires, compare. Match → silent exit 0 (no re-dump). Mismatch
|
|
66
|
+
// (canonical/ corpus changed → planContext bumps revision_hash) → emit AND
|
|
67
|
+
// update sidecar.
|
|
68
|
+
//
|
|
69
|
+
// The revision_hash is supplied by `fabric plan-context-hint --all`'s JSON
|
|
70
|
+
// payload (carried in payload.revision_hash since rc.5). Reusing the existing
|
|
71
|
+
// hash primitive keeps the gating predicate exactly aligned with the "is the
|
|
72
|
+
// knowledge graph different from last time?" question — no second hashing
|
|
73
|
+
// scheme to maintain. computeRevisionHash() is not needed at this layer; we
|
|
74
|
+
// compare the strings the CLI hands us.
|
|
75
|
+
//
|
|
76
|
+
// rc.8 underseed self-check: the retired `.fabric/.import-requested` sentinel
|
|
77
|
+
// mechanism is replaced by a deterministic three-condition probe in
|
|
78
|
+
// shouldRecommendImport(). When the probe says "recommend", a one-line
|
|
79
|
+
// `/fabric-import` banner is appended to the broad-injection output and
|
|
80
|
+
// the revision_hash gate is bypassed FOR THE BANNER ONLY (the broad-summary
|
|
81
|
+
// body itself remains hash-gated). See shouldRecommendImport() below for
|
|
82
|
+
// the full truth table.
|
|
83
|
+
// -----------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
const FABRIC_DIR_REL = ".fabric";
|
|
86
|
+
const SESSIONSTART_HASH_CACHE_FILE = join(".fabric", ".cache", "sessionstart-last-hash");
|
|
87
|
+
|
|
88
|
+
// rc.8 underseed self-check constants (mirror fabric-hint.cjs ~line 76 / 83).
|
|
89
|
+
// Intentionally duplicated inline — hooks are independent .cjs files and
|
|
90
|
+
// cannot `require` each other. If a third hook ever needs the same logic,
|
|
91
|
+
// refactor into packages/cli/templates/hooks/lib/. Keep these values in sync
|
|
92
|
+
// with packages/cli/templates/hooks/fabric-hint.cjs.
|
|
93
|
+
const FABRIC_CONFIG_FILE = "fabric-config.json";
|
|
94
|
+
const AGENTS_META_FILE = "agents.meta.json";
|
|
95
|
+
const IMPORT_STATE_FILE = ".import-state.json";
|
|
96
|
+
const KNOWLEDGE_CANONICAL_TYPES = [
|
|
97
|
+
"decisions",
|
|
98
|
+
"pitfalls",
|
|
99
|
+
"guidelines",
|
|
100
|
+
"models",
|
|
101
|
+
"processes",
|
|
102
|
+
];
|
|
103
|
+
const DEFAULT_UNDERSEED_NODE_THRESHOLD = 10;
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Read the previously-emitted revision_hash from
|
|
107
|
+
* `.fabric/.cache/sessionstart-last-hash`. Missing file / read failure /
|
|
108
|
+
* empty file → null (treat as "no prior emit", forces re-emit).
|
|
109
|
+
*
|
|
110
|
+
* NEVER throws — best-effort read.
|
|
111
|
+
*/
|
|
112
|
+
function readSessionStartLastHash(projectRoot) {
|
|
113
|
+
try {
|
|
114
|
+
const p = join(projectRoot, SESSIONSTART_HASH_CACHE_FILE);
|
|
115
|
+
if (!existsSync(p)) return null;
|
|
116
|
+
const raw = readFileSync(p, "utf8").trim();
|
|
117
|
+
return raw.length > 0 ? raw : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Write `hash` to `.fabric/.cache/sessionstart-last-hash` so subsequent
|
|
125
|
+
* SessionStart fires can compare. Creates the directory if missing.
|
|
126
|
+
* Best-effort: any write failure is swallowed so a read-only .fabric/
|
|
127
|
+
* never blocks session start.
|
|
128
|
+
*/
|
|
129
|
+
function writeSessionStartLastHash(projectRoot, hash) {
|
|
130
|
+
try {
|
|
131
|
+
if (typeof hash !== "string" || hash.length === 0) return;
|
|
132
|
+
const p = join(projectRoot, SESSIONSTART_HASH_CACHE_FILE);
|
|
133
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
134
|
+
writeFileSync(p, hash, "utf8");
|
|
135
|
+
} catch {
|
|
136
|
+
// Silent — sidecar failure must never block session start.
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// -----------------------------------------------------------------------------
|
|
141
|
+
// rc.8 underseed self-check helpers.
|
|
142
|
+
//
|
|
143
|
+
// These three helpers (countCanonicalNodes / readUnderseedThreshold /
|
|
144
|
+
// isImportTouched) are inline copies of the equivalent logic in
|
|
145
|
+
// packages/cli/templates/hooks/fabric-hint.cjs (~lines 218 / 749). Hooks
|
|
146
|
+
// cannot `require` each other (each .cjs is rendered as a standalone template
|
|
147
|
+
// at init time), so duplication is the documented convention. Cross-reference:
|
|
148
|
+
// keep both copies in sync; if a third hook needs the same logic, extract to
|
|
149
|
+
// packages/cli/templates/hooks/lib/.
|
|
150
|
+
// -----------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Count canonical knowledge entries across the five canonical type subdirs
|
|
154
|
+
* (decisions / pitfalls / guidelines / models / processes). Pending entries
|
|
155
|
+
* are NOT counted — they are proposals, not seeded knowledge.
|
|
156
|
+
*
|
|
157
|
+
* Returns the integer count. ENOENT / unreadable subdir → silently treated as
|
|
158
|
+
* zero (preserves never-block-on-failure invariant). Filters on `.md` suffix
|
|
159
|
+
* only; the more-precise canonical filename pattern check is owned by
|
|
160
|
+
* doctor.ts (the hook is a coarse signal, not a lint).
|
|
161
|
+
*/
|
|
162
|
+
function countCanonicalNodes(projectRoot) {
|
|
163
|
+
const knowledgeRoot = join(projectRoot, FABRIC_DIR_REL, "knowledge");
|
|
164
|
+
if (!existsSync(knowledgeRoot)) {
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
let count = 0;
|
|
168
|
+
for (const type of KNOWLEDGE_CANONICAL_TYPES) {
|
|
169
|
+
const typeDir = join(knowledgeRoot, type);
|
|
170
|
+
if (!existsSync(typeDir)) continue;
|
|
171
|
+
let entries;
|
|
172
|
+
try {
|
|
173
|
+
entries = readdirSync(typeDir);
|
|
174
|
+
} catch {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
for (const entry of entries) {
|
|
178
|
+
if (entry.endsWith(".md")) {
|
|
179
|
+
count += 1;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return count;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Resolve the underseed-node threshold from .fabric/fabric-config.json
|
|
188
|
+
* (underseed_node_threshold), falling back to DEFAULT_UNDERSEED_NODE_THRESHOLD.
|
|
189
|
+
* Any read/parse failure → default (never block on config errors).
|
|
190
|
+
*/
|
|
191
|
+
function readUnderseedThreshold(projectRoot) {
|
|
192
|
+
const configPath = join(projectRoot, FABRIC_DIR_REL, FABRIC_CONFIG_FILE);
|
|
193
|
+
if (!existsSync(configPath)) return DEFAULT_UNDERSEED_NODE_THRESHOLD;
|
|
194
|
+
try {
|
|
195
|
+
const parsed = JSON.parse(readFileSync(configPath, "utf8"));
|
|
196
|
+
const v = parsed && parsed.underseed_node_threshold;
|
|
197
|
+
if (typeof v === "number" && Number.isFinite(v) && v > 0) return v;
|
|
198
|
+
} catch {
|
|
199
|
+
// fall through to default
|
|
200
|
+
}
|
|
201
|
+
return DEFAULT_UNDERSEED_NODE_THRESHOLD;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Classify the on-disk import lifecycle by reading
|
|
206
|
+
* `.fabric/.import-state.json`. Returns one of:
|
|
207
|
+
* - 'absent' — state file missing → user has NEVER started import
|
|
208
|
+
* - 'in_progress' — file present, phase is anything that is not 'complete'
|
|
209
|
+
* (covers 'P1-done', 'P2-done', 'phase 1', 'in_progress',
|
|
210
|
+
* '1', and any other live-import marker)
|
|
211
|
+
* - 'complete' — file present and phase === 'complete'
|
|
212
|
+
* - 'error' — file present but unreadable / unparseable JSON
|
|
213
|
+
*
|
|
214
|
+
* Recommendation rule (see shouldRecommendImport): only 'absent' triggers a
|
|
215
|
+
* banner — both 'in_progress' (user is actively importing) and 'complete'
|
|
216
|
+
* (user already imported) suppress the banner. 'error' also suppresses
|
|
217
|
+
* (defensive: do not nag when state is unreadable, the user has clearly
|
|
218
|
+
* touched the file).
|
|
219
|
+
*/
|
|
220
|
+
function isImportTouched(projectRoot) {
|
|
221
|
+
const statePath = join(projectRoot, FABRIC_DIR_REL, IMPORT_STATE_FILE);
|
|
222
|
+
if (!existsSync(statePath)) return "absent";
|
|
223
|
+
let raw;
|
|
224
|
+
try {
|
|
225
|
+
raw = readFileSync(statePath, "utf8");
|
|
226
|
+
} catch {
|
|
227
|
+
return "error";
|
|
228
|
+
}
|
|
229
|
+
let parsed;
|
|
230
|
+
try {
|
|
231
|
+
parsed = JSON.parse(raw);
|
|
232
|
+
} catch {
|
|
233
|
+
return "error";
|
|
234
|
+
}
|
|
235
|
+
if (!parsed || typeof parsed !== "object") return "error";
|
|
236
|
+
return parsed.phase === "complete" ? "complete" : "in_progress";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* rc.8 underseed self-check: determine whether the SessionStart hook should
|
|
241
|
+
* surface the one-line `/fabric-import` recommendation banner.
|
|
242
|
+
*
|
|
243
|
+
* Three-condition truth table (ALL must hold to return true):
|
|
244
|
+
* 1. `.fabric/agents.meta.json` exists
|
|
245
|
+
* (workspace has been `fabric init`-ed; otherwise the recommendation
|
|
246
|
+
* is meaningless — `fabric-import` requires init's baseline scan).
|
|
247
|
+
* 2. countCanonicalNodes(cwd) < readUnderseedThreshold(cwd)
|
|
248
|
+
* (knowledge graph is sparse — import would meaningfully enrich it).
|
|
249
|
+
* 3. isImportTouched(cwd) === 'absent'
|
|
250
|
+
* (.import-state.json is missing entirely; user has neither started
|
|
251
|
+
* nor completed an import. ANY phase value — including 'in_progress'
|
|
252
|
+
* and 'complete' — returns false because the user has either started
|
|
253
|
+
* or finished.)
|
|
254
|
+
*
|
|
255
|
+
* Best-effort: any unexpected error → return false (do not nag on faults).
|
|
256
|
+
*/
|
|
257
|
+
function shouldRecommendImport(projectRoot) {
|
|
258
|
+
try {
|
|
259
|
+
const metaPath = join(projectRoot, FABRIC_DIR_REL, AGENTS_META_FILE);
|
|
260
|
+
if (!existsSync(metaPath)) return false;
|
|
261
|
+
|
|
262
|
+
const threshold = readUnderseedThreshold(projectRoot);
|
|
263
|
+
const nodeCount = countCanonicalNodes(projectRoot);
|
|
264
|
+
if (nodeCount >= threshold) return false;
|
|
265
|
+
|
|
266
|
+
if (isImportTouched(projectRoot) !== "absent") return false;
|
|
267
|
+
|
|
268
|
+
return true;
|
|
269
|
+
} catch {
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// -----------------------------------------------------------------------------
|
|
275
|
+
// CONSTANTS
|
|
276
|
+
// -----------------------------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
// Per-type truncation triggers when total narrow entries > 30. The threshold
|
|
279
|
+
// was originally aligned with the rc.5 plan-context degenerate-mode cutoff,
|
|
280
|
+
// which is now retired (rc.7 T9 — see docs/decisions/rc5-a3-superseded.md).
|
|
281
|
+
// We keep 30 here as a stable rendering boundary independent of that protocol
|
|
282
|
+
// change: it's a UI-density choice, not a wire-shape one.
|
|
283
|
+
const TRUNCATION_THRESHOLD = 30;
|
|
284
|
+
|
|
285
|
+
// `fabric plan-context-hint` is a thin wrapper over planContext(); on a
|
|
286
|
+
// well-seeded repo it returns in ~100ms. Two-second cap is defensive — any
|
|
287
|
+
// pathological hang must not stall session start.
|
|
288
|
+
const CLI_TIMEOUT_MS = 2000;
|
|
289
|
+
|
|
290
|
+
// Maximum summary length per entry. Keeps each line bounded so stderr does
|
|
291
|
+
// not blow up terminal width with multi-paragraph summaries from sloppy
|
|
292
|
+
// pending entries. Truncation appends an ellipsis.
|
|
293
|
+
const SUMMARY_MAX_LEN = 80;
|
|
294
|
+
|
|
295
|
+
// Canonical type order — render groups in this sequence so output is stable
|
|
296
|
+
// across runs (Object.keys iteration order is insertion order, but the JSON
|
|
297
|
+
// payload may shuffle if planContext's internal sort changes). Unknown types
|
|
298
|
+
// are appended after canonical types in encounter order.
|
|
299
|
+
const CANONICAL_TYPE_ORDER = [
|
|
300
|
+
"decision",
|
|
301
|
+
"pitfall",
|
|
302
|
+
"guideline",
|
|
303
|
+
"model",
|
|
304
|
+
"process",
|
|
305
|
+
];
|
|
306
|
+
|
|
307
|
+
// Canonical maturity order for truncation rendering. proven is the highest-
|
|
308
|
+
// signal tier so it gets full per-line treatment; verified gets id-list; draft
|
|
309
|
+
// gets count-only. Unknown maturities fall through to the verified bucket.
|
|
310
|
+
const MATURITY_PROVEN = "proven";
|
|
311
|
+
const MATURITY_VERIFIED = "verified";
|
|
312
|
+
const MATURITY_DRAFT = "draft";
|
|
313
|
+
|
|
314
|
+
// rc.8 underseed self-check banner text. Single line, mirrors the emoji-prefix
|
|
315
|
+
// style of other Fabric banners (cf. fabric-hint.cjs Signal C `📋 Fabric:`).
|
|
316
|
+
const IMPORT_RECOMMENDATION_BANNER =
|
|
317
|
+
" 📋 Fabric: 知识库稀疏,是否调 /fabric-import 从 git 历史与现有文档回灌知识?";
|
|
318
|
+
|
|
319
|
+
// -----------------------------------------------------------------------------
|
|
320
|
+
// CLI invocation
|
|
321
|
+
// -----------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Spawn `fabric plan-context-hint --all` and return parsed JSON. Returns
|
|
325
|
+
* null on any failure (ENOENT, non-zero exit, malformed JSON). Never throws.
|
|
326
|
+
*
|
|
327
|
+
* spawn strategy: try `fabric` first (user-PATH install) then `fab` (the
|
|
328
|
+
* alternate bin name shipped by @fenglimg/fabric-cli). If neither is on PATH,
|
|
329
|
+
* return null — the hook stays silent rather than nagging about install state.
|
|
330
|
+
*/
|
|
331
|
+
function invokePlanContextHint(cwd) {
|
|
332
|
+
const candidates = ["fabric", "fab"];
|
|
333
|
+
for (const bin of candidates) {
|
|
334
|
+
let res;
|
|
335
|
+
try {
|
|
336
|
+
res = spawnSync(bin, ["plan-context-hint", "--all"], {
|
|
337
|
+
cwd,
|
|
338
|
+
encoding: "utf8",
|
|
339
|
+
timeout: CLI_TIMEOUT_MS,
|
|
340
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
341
|
+
});
|
|
342
|
+
} catch {
|
|
343
|
+
continue; // spawn throw (extremely rare) — try next candidate
|
|
344
|
+
}
|
|
345
|
+
// ENOENT surfaces as error on the result object.
|
|
346
|
+
if (res.error || res.status === null || res.status !== 0) continue;
|
|
347
|
+
const raw = (res.stdout || "").trim();
|
|
348
|
+
if (raw.length === 0) continue;
|
|
349
|
+
try {
|
|
350
|
+
const parsed = JSON.parse(raw);
|
|
351
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
352
|
+
} catch {
|
|
353
|
+
// malformed JSON — try next bin (unlikely to differ, but no harm)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// -----------------------------------------------------------------------------
|
|
360
|
+
// Rendering
|
|
361
|
+
// -----------------------------------------------------------------------------
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Group narrow entries by type (preserving canonical order), then by maturity
|
|
365
|
+
* within each type. Returns { typeOrder: string[], byType: Map<type, Map<maturity, entries[]>> }.
|
|
366
|
+
*/
|
|
367
|
+
function groupEntries(narrow) {
|
|
368
|
+
const byType = new Map();
|
|
369
|
+
const encounterOrder = [];
|
|
370
|
+
|
|
371
|
+
for (const entry of narrow) {
|
|
372
|
+
const type = entry.type || "unknown";
|
|
373
|
+
if (!byType.has(type)) {
|
|
374
|
+
byType.set(type, new Map());
|
|
375
|
+
encounterOrder.push(type);
|
|
376
|
+
}
|
|
377
|
+
const maturity = entry.maturity || "unknown";
|
|
378
|
+
const maturityMap = byType.get(type);
|
|
379
|
+
if (!maturityMap.has(maturity)) maturityMap.set(maturity, []);
|
|
380
|
+
maturityMap.get(maturity).push(entry);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Stable type order: canonical types first (when present), then anything
|
|
384
|
+
// else in encounter order.
|
|
385
|
+
const typeOrder = [];
|
|
386
|
+
for (const t of CANONICAL_TYPE_ORDER) {
|
|
387
|
+
if (byType.has(t)) typeOrder.push(t);
|
|
388
|
+
}
|
|
389
|
+
for (const t of encounterOrder) {
|
|
390
|
+
if (!CANONICAL_TYPE_ORDER.includes(t)) typeOrder.push(t);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return { typeOrder, byType };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function truncateSummary(raw) {
|
|
397
|
+
const s = typeof raw === "string" ? raw : "";
|
|
398
|
+
// Collapse newlines / runs of whitespace so each entry fits one line.
|
|
399
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
400
|
+
if (flat.length <= SUMMARY_MAX_LEN) return flat;
|
|
401
|
+
return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function formatEntryLine(entry) {
|
|
405
|
+
const id = entry.id || "(no-id)";
|
|
406
|
+
const summary = truncateSummary(entry.summary);
|
|
407
|
+
return summary.length > 0 ? ` - ${id} · ${summary}` : ` - ${id}`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Render full per-type listing — used when total narrow entries <= 30.
|
|
412
|
+
* Each entry gets one line: ` - <id> · <summary>`. Type/maturity headers
|
|
413
|
+
* group the listing.
|
|
414
|
+
*/
|
|
415
|
+
function renderFull(narrow) {
|
|
416
|
+
const { typeOrder, byType } = groupEntries(narrow);
|
|
417
|
+
const lines = [];
|
|
418
|
+
for (const type of typeOrder) {
|
|
419
|
+
const maturityMap = byType.get(type);
|
|
420
|
+
// Within each type, render maturity buckets in proven > verified > draft
|
|
421
|
+
// > unknown order so the most-trusted entries surface first.
|
|
422
|
+
const maturities = [];
|
|
423
|
+
for (const m of [MATURITY_PROVEN, MATURITY_VERIFIED, MATURITY_DRAFT]) {
|
|
424
|
+
if (maturityMap.has(m)) maturities.push(m);
|
|
425
|
+
}
|
|
426
|
+
for (const m of maturityMap.keys()) {
|
|
427
|
+
if (![MATURITY_PROVEN, MATURITY_VERIFIED, MATURITY_DRAFT].includes(m)) {
|
|
428
|
+
maturities.push(m);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
for (const maturity of maturities) {
|
|
432
|
+
lines.push(` [${type}] (${maturity}):`);
|
|
433
|
+
for (const entry of maturityMap.get(maturity)) {
|
|
434
|
+
lines.push(formatEntryLine(entry));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return lines;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Render grouped truncation — used when total narrow entries > 30. Per the
|
|
443
|
+
* task spec: proven entries get full per-line treatment; verified entries get
|
|
444
|
+
* an inline id list (no summary); draft (and unknown) buckets collapse to a
|
|
445
|
+
* count.
|
|
446
|
+
*/
|
|
447
|
+
function renderTruncated(narrow) {
|
|
448
|
+
const { typeOrder, byType } = groupEntries(narrow);
|
|
449
|
+
const lines = [];
|
|
450
|
+
for (const type of typeOrder) {
|
|
451
|
+
const maturityMap = byType.get(type);
|
|
452
|
+
|
|
453
|
+
// Proven: full per-line listing.
|
|
454
|
+
const proven = maturityMap.get(MATURITY_PROVEN);
|
|
455
|
+
if (proven && proven.length > 0) {
|
|
456
|
+
lines.push(` [${type}] proven (${proven.length}):`);
|
|
457
|
+
for (const entry of proven) {
|
|
458
|
+
lines.push(formatEntryLine(entry));
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Verified: inline id list.
|
|
463
|
+
const verified = maturityMap.get(MATURITY_VERIFIED);
|
|
464
|
+
if (verified && verified.length > 0) {
|
|
465
|
+
const ids = verified.map((e) => e.id || "(no-id)").join(", ");
|
|
466
|
+
lines.push(` [${type}] verified (${verified.length}): ${ids}`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Draft + any unknown maturity: count-only.
|
|
470
|
+
let countOnly = 0;
|
|
471
|
+
for (const [maturity, entries] of maturityMap.entries()) {
|
|
472
|
+
if (maturity === MATURITY_PROVEN || maturity === MATURITY_VERIFIED) continue;
|
|
473
|
+
countOnly += entries.length;
|
|
474
|
+
}
|
|
475
|
+
if (countOnly > 0) {
|
|
476
|
+
lines.push(` [${type}] draft: ${countOnly} entries`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return lines;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Top-level rendering — picks the mode based on entry count and prepends the
|
|
484
|
+
* session-start banner + appends the revision_hash and usage hint footers.
|
|
485
|
+
*
|
|
486
|
+
* Returns an array of lines (one stderr write per line keeps the formatter
|
|
487
|
+
* trivial and testable). Returns [] when there is nothing meaningful to say
|
|
488
|
+
* (empty narrow set) so callers know to stay silent.
|
|
489
|
+
*/
|
|
490
|
+
function renderSummary(payload) {
|
|
491
|
+
const narrow = Array.isArray(payload && payload.narrow) ? payload.narrow : [];
|
|
492
|
+
if (narrow.length === 0) return [];
|
|
493
|
+
|
|
494
|
+
const truncated = narrow.length > TRUNCATION_THRESHOLD;
|
|
495
|
+
const banner = truncated
|
|
496
|
+
? `[fabric] Session start — ${narrow.length} broad-scoped knowledge entries available (truncated):`
|
|
497
|
+
: `[fabric] Session start — ${narrow.length} broad-scoped knowledge entries available:`;
|
|
498
|
+
|
|
499
|
+
const body = truncated ? renderTruncated(narrow) : renderFull(narrow);
|
|
500
|
+
|
|
501
|
+
const lines = [banner, ...body];
|
|
502
|
+
const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
|
|
503
|
+
if (revHash !== null && revHash.length > 0) {
|
|
504
|
+
lines.push(` revision_hash: ${revHash}`);
|
|
505
|
+
}
|
|
506
|
+
lines.push(" Use `fab_get_knowledge_sections` to fetch full content.");
|
|
507
|
+
return lines;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// -----------------------------------------------------------------------------
|
|
511
|
+
// Main entry — invoked both as a CLI (require.main === module) and in-process
|
|
512
|
+
// by tests. Wraps the entire flow in try/catch: ANY error → silent exit 0.
|
|
513
|
+
// -----------------------------------------------------------------------------
|
|
514
|
+
|
|
515
|
+
function main(env, stdio) {
|
|
516
|
+
try {
|
|
517
|
+
const cwd = (env && env.cwd) || process.cwd();
|
|
518
|
+
const err = (stdio && stdio.stderr) || process.stderr;
|
|
519
|
+
|
|
520
|
+
// Test seam: env.payload short-circuits the CLI spawn so unit tests can
|
|
521
|
+
// feed canned plan-context-hint JSON without depending on a built CLI.
|
|
522
|
+
const payload =
|
|
523
|
+
env && env.payload !== undefined ? env.payload : invokePlanContextHint(cwd);
|
|
524
|
+
if (payload === null || payload === undefined) return; // silent
|
|
525
|
+
|
|
526
|
+
// rc.8 underseed self-check: decide whether to surface the one-line
|
|
527
|
+
// `/fabric-import` recommendation. The decision is taken BEFORE the
|
|
528
|
+
// revision_hash gate so the banner can bypass it (an unchanged
|
|
529
|
+
// knowledge graph would otherwise hide the recommendation forever).
|
|
530
|
+
// The broad-summary BODY itself remains hash-gated below — only the
|
|
531
|
+
// banner line is unconditionally emitted when the probe says so.
|
|
532
|
+
const recommendImport = shouldRecommendImport(cwd);
|
|
533
|
+
|
|
534
|
+
// rc.7 T8: revision_hash gate. If the CLI payload carries a stable
|
|
535
|
+
// revision_hash and it matches the previously-emitted hash recorded in
|
|
536
|
+
// the sidecar, the knowledge graph is unchanged since last session →
|
|
537
|
+
// suppress the broad-summary body. The import-recommendation banner
|
|
538
|
+
// (when applicable) is still emitted below regardless of this gate.
|
|
539
|
+
const currentHash =
|
|
540
|
+
typeof payload.revision_hash === "string" ? payload.revision_hash : "";
|
|
541
|
+
let bodySuppressed = false;
|
|
542
|
+
if (currentHash.length > 0) {
|
|
543
|
+
const lastHash = readSessionStartLastHash(cwd);
|
|
544
|
+
if (lastHash !== null && lastHash === currentHash) {
|
|
545
|
+
bodySuppressed = true;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Build emitted lines. When the body is hash-suppressed we skip the
|
|
550
|
+
// broad summary entirely; only the import banner (if applicable) goes
|
|
551
|
+
// to stderr in that case.
|
|
552
|
+
const lines = bodySuppressed ? [] : renderSummary(payload);
|
|
553
|
+
|
|
554
|
+
if (recommendImport) {
|
|
555
|
+
lines.push(IMPORT_RECOMMENDATION_BANNER);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
if (lines.length === 0) return; // nothing to say — silent exit
|
|
559
|
+
|
|
560
|
+
for (const line of lines) {
|
|
561
|
+
err.write(`${line}\n`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// Update sidecar AFTER successful emit. We only persist the hash when
|
|
565
|
+
// the broad-summary body actually went out (i.e. the gate let the body
|
|
566
|
+
// through). If the body was suppressed but the banner emitted on its
|
|
567
|
+
// own, we deliberately do NOT bump the sidecar — the next session
|
|
568
|
+
// should still get to compare against the prior canonical-graph hash
|
|
569
|
+
// and re-emit the body when the graph actually changes.
|
|
570
|
+
if (!bodySuppressed && currentHash.length > 0) {
|
|
571
|
+
writeSessionStartLastHash(cwd, currentHash);
|
|
572
|
+
}
|
|
573
|
+
} catch {
|
|
574
|
+
// Silent — never block session start on hook failure.
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
module.exports = {
|
|
579
|
+
main,
|
|
580
|
+
invokePlanContextHint,
|
|
581
|
+
groupEntries,
|
|
582
|
+
renderFull,
|
|
583
|
+
renderTruncated,
|
|
584
|
+
renderSummary,
|
|
585
|
+
truncateSummary,
|
|
586
|
+
// rc.7 T8: revision_hash gating sidecar helpers (exported for unit testing).
|
|
587
|
+
readSessionStartLastHash,
|
|
588
|
+
writeSessionStartLastHash,
|
|
589
|
+
// rc.8 underseed self-check helpers (exported for unit testing).
|
|
590
|
+
countCanonicalNodes,
|
|
591
|
+
readUnderseedThreshold,
|
|
592
|
+
isImportTouched,
|
|
593
|
+
shouldRecommendImport,
|
|
594
|
+
CONSTANTS: {
|
|
595
|
+
TRUNCATION_THRESHOLD,
|
|
596
|
+
CLI_TIMEOUT_MS,
|
|
597
|
+
SUMMARY_MAX_LEN,
|
|
598
|
+
CANONICAL_TYPE_ORDER,
|
|
599
|
+
MATURITY_PROVEN,
|
|
600
|
+
MATURITY_VERIFIED,
|
|
601
|
+
MATURITY_DRAFT,
|
|
602
|
+
SESSIONSTART_HASH_CACHE_FILE,
|
|
603
|
+
DEFAULT_UNDERSEED_NODE_THRESHOLD,
|
|
604
|
+
KNOWLEDGE_CANONICAL_TYPES,
|
|
605
|
+
IMPORT_RECOMMENDATION_BANNER,
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
if (require.main === module) {
|
|
610
|
+
main({ cwd: process.cwd() }, { stderr: process.stderr });
|
|
611
|
+
process.exit(0);
|
|
612
|
+
}
|