@fenglimg/fabric-cli 1.8.0-rc.3 → 2.0.0-rc.8
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-74SZWYPH.js +658 -0
- package/dist/chunk-EYIDD2YS.js +1000 -0
- package/dist/{chunk-QPCRBQ5Y.js → chunk-OBQU6NHO.js} +1 -52
- package/dist/chunk-WWNXR34K.js +49 -0
- package/dist/doctor-T7JWODKG.js +282 -0
- package/dist/hooks-Y74Y5LQS.js +12 -0
- package/dist/index.js +7 -5
- package/dist/{init-7EYGUJNJ.js → init-55WZSUK6.js} +312 -1022
- package/dist/plan-context-hint-QMUPAXIB.js +98 -0
- package/dist/scan-LMK3UCWL.js +22 -0
- package/dist/{serve-466QXQ5Q.js → serve-H554BHLG.js} +8 -4
- package/package.json +3 -3
- package/templates/agents-md/AGENTS.md.template +55 -17
- 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 +1307 -0
- package/templates/hooks/knowledge-hint-broad.cjs +464 -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 +588 -0
- package/templates/skills/fabric-review/SKILL.md +382 -0
- package/dist/chunk-NMMUETVK.js +0 -216
- package/dist/doctor-F52XWWZC.js +0 -98
- package/dist/scan-NNBNGIZG.js +0 -12
- package/templates/agents-md/variants/cocos.md +0 -20
- package/templates/agents-md/variants/next.md +0 -20
- package/templates/agents-md/variants/vite.md +0 -20
- package/templates/bootstrap/GEMINI.md +0 -8
- package/templates/bootstrap/roo-fabric.md +0 -5
- package/templates/bootstrap/windsurf-fabric.md +0 -5
- package/templates/claude-hooks/fabric-init-reminder.cjs +0 -18
- package/templates/claude-skills/fabric-init/SKILL.md +0 -163
- package/templates/codex-hooks/fabric-session-start.cjs +0 -19
- package/templates/codex-hooks/fabric-stop-reminder.cjs +0 -18
- package/templates/codex-skills/fabric-init/SKILL.md +0 -162
- package/templates/husky/pre-commit +0 -9
- package/templates/skill-source/fabric-init/SOURCE.md +0 -157
- package/templates/skill-source/fabric-init/clients.json +0 -17
|
@@ -0,0 +1,464 @@
|
|
|
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 { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs");
|
|
51
|
+
const { dirname, join } = require("node:path");
|
|
52
|
+
|
|
53
|
+
// -----------------------------------------------------------------------------
|
|
54
|
+
// rc.7 T8: SessionStart revision_hash gating.
|
|
55
|
+
//
|
|
56
|
+
// Q-14 problem: every SessionStart re-dumped the full broad knowledge list,
|
|
57
|
+
// causing banner blindness. Solution: hash-of-canonical-graph gating — record
|
|
58
|
+
// the last-emitted `payload.revision_hash` to a sidecar; on subsequent
|
|
59
|
+
// SessionStart fires, compare. Match → silent exit 0 (no re-dump). Mismatch
|
|
60
|
+
// (canonical/ corpus changed → planContext bumps revision_hash) → emit AND
|
|
61
|
+
// update sidecar.
|
|
62
|
+
//
|
|
63
|
+
// The revision_hash is supplied by `fabric plan-context-hint --all`'s JSON
|
|
64
|
+
// payload (carried in payload.revision_hash since rc.5). Reusing the existing
|
|
65
|
+
// hash primitive keeps the gating predicate exactly aligned with the "is the
|
|
66
|
+
// knowledge graph different from last time?" question — no second hashing
|
|
67
|
+
// scheme to maintain. computeRevisionHash() is not needed at this layer; we
|
|
68
|
+
// compare the strings the CLI hands us.
|
|
69
|
+
//
|
|
70
|
+
// rc.7 T1 (sentinel hand-off) overrides this gate: a `.fabric/.import-requested`
|
|
71
|
+
// sentinel forces emission regardless of revision_hash, because the user has
|
|
72
|
+
// asked (via `fabric init` Y-confirm) for the import recommendation to surface
|
|
73
|
+
// on next SessionStart. That branch is layered on top in main() — see T1
|
|
74
|
+
// implementation.
|
|
75
|
+
// -----------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
const FABRIC_DIR_REL = ".fabric";
|
|
78
|
+
const SESSIONSTART_HASH_CACHE_FILE = join(".fabric", ".cache", "sessionstart-last-hash");
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Read the previously-emitted revision_hash from
|
|
82
|
+
* `.fabric/.cache/sessionstart-last-hash`. Missing file / read failure /
|
|
83
|
+
* empty file → null (treat as "no prior emit", forces re-emit).
|
|
84
|
+
*
|
|
85
|
+
* NEVER throws — best-effort read.
|
|
86
|
+
*/
|
|
87
|
+
function readSessionStartLastHash(projectRoot) {
|
|
88
|
+
try {
|
|
89
|
+
const p = join(projectRoot, SESSIONSTART_HASH_CACHE_FILE);
|
|
90
|
+
if (!existsSync(p)) return null;
|
|
91
|
+
const raw = readFileSync(p, "utf8").trim();
|
|
92
|
+
return raw.length > 0 ? raw : null;
|
|
93
|
+
} catch {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Write `hash` to `.fabric/.cache/sessionstart-last-hash` so subsequent
|
|
100
|
+
* SessionStart fires can compare. Creates the directory if missing.
|
|
101
|
+
* Best-effort: any write failure is swallowed so a read-only .fabric/
|
|
102
|
+
* never blocks session start.
|
|
103
|
+
*/
|
|
104
|
+
function writeSessionStartLastHash(projectRoot, hash) {
|
|
105
|
+
try {
|
|
106
|
+
if (typeof hash !== "string" || hash.length === 0) return;
|
|
107
|
+
const p = join(projectRoot, SESSIONSTART_HASH_CACHE_FILE);
|
|
108
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
109
|
+
writeFileSync(p, hash, "utf8");
|
|
110
|
+
} catch {
|
|
111
|
+
// Silent — sidecar failure must never block session start.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* rc.7 T1 sentinel pickup: `.fabric/.import-requested` is an empty marker
|
|
117
|
+
* file written by `fabric init` (clack.confirm Y answer) signalling that
|
|
118
|
+
* the user wants the next SessionStart to recommend `fabric-import`.
|
|
119
|
+
*
|
|
120
|
+
* When the sentinel is present, the gate is overridden — the broad-injection
|
|
121
|
+
* banner is appended with the import recommendation line and the
|
|
122
|
+
* revision_hash gate is bypassed entirely (we always want to surface the
|
|
123
|
+
* recommendation until the import Skill clears the sentinel).
|
|
124
|
+
*
|
|
125
|
+
* Best-effort presence check. NEVER throws.
|
|
126
|
+
*/
|
|
127
|
+
function isImportRequestedSentinelPresent(projectRoot) {
|
|
128
|
+
try {
|
|
129
|
+
return existsSync(join(projectRoot, FABRIC_DIR_REL, ".import-requested"));
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// -----------------------------------------------------------------------------
|
|
136
|
+
// CONSTANTS
|
|
137
|
+
// -----------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
// Per-type truncation triggers when total narrow entries > 30. The threshold
|
|
140
|
+
// was originally aligned with the rc.5 plan-context degenerate-mode cutoff,
|
|
141
|
+
// which is now retired (rc.7 T9 — see docs/decisions/rc5-a3-superseded.md).
|
|
142
|
+
// We keep 30 here as a stable rendering boundary independent of that protocol
|
|
143
|
+
// change: it's a UI-density choice, not a wire-shape one.
|
|
144
|
+
const TRUNCATION_THRESHOLD = 30;
|
|
145
|
+
|
|
146
|
+
// `fabric plan-context-hint` is a thin wrapper over planContext(); on a
|
|
147
|
+
// well-seeded repo it returns in ~100ms. Two-second cap is defensive — any
|
|
148
|
+
// pathological hang must not stall session start.
|
|
149
|
+
const CLI_TIMEOUT_MS = 2000;
|
|
150
|
+
|
|
151
|
+
// Maximum summary length per entry. Keeps each line bounded so stderr does
|
|
152
|
+
// not blow up terminal width with multi-paragraph summaries from sloppy
|
|
153
|
+
// pending entries. Truncation appends an ellipsis.
|
|
154
|
+
const SUMMARY_MAX_LEN = 80;
|
|
155
|
+
|
|
156
|
+
// Canonical type order — render groups in this sequence so output is stable
|
|
157
|
+
// across runs (Object.keys iteration order is insertion order, but the JSON
|
|
158
|
+
// payload may shuffle if planContext's internal sort changes). Unknown types
|
|
159
|
+
// are appended after canonical types in encounter order.
|
|
160
|
+
const CANONICAL_TYPE_ORDER = [
|
|
161
|
+
"decision",
|
|
162
|
+
"pitfall",
|
|
163
|
+
"guideline",
|
|
164
|
+
"model",
|
|
165
|
+
"process",
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
// Canonical maturity order for truncation rendering. proven is the highest-
|
|
169
|
+
// signal tier so it gets full per-line treatment; verified gets id-list; draft
|
|
170
|
+
// gets count-only. Unknown maturities fall through to the verified bucket.
|
|
171
|
+
const MATURITY_PROVEN = "proven";
|
|
172
|
+
const MATURITY_VERIFIED = "verified";
|
|
173
|
+
const MATURITY_DRAFT = "draft";
|
|
174
|
+
|
|
175
|
+
// -----------------------------------------------------------------------------
|
|
176
|
+
// CLI invocation
|
|
177
|
+
// -----------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Spawn `fabric plan-context-hint --all` and return parsed JSON. Returns
|
|
181
|
+
* null on any failure (ENOENT, non-zero exit, malformed JSON). Never throws.
|
|
182
|
+
*
|
|
183
|
+
* spawn strategy: try `fabric` first (user-PATH install) then `fab` (the
|
|
184
|
+
* alternate bin name shipped by @fenglimg/fabric-cli). If neither is on PATH,
|
|
185
|
+
* return null — the hook stays silent rather than nagging about install state.
|
|
186
|
+
*/
|
|
187
|
+
function invokePlanContextHint(cwd) {
|
|
188
|
+
const candidates = ["fabric", "fab"];
|
|
189
|
+
for (const bin of candidates) {
|
|
190
|
+
let res;
|
|
191
|
+
try {
|
|
192
|
+
res = spawnSync(bin, ["plan-context-hint", "--all"], {
|
|
193
|
+
cwd,
|
|
194
|
+
encoding: "utf8",
|
|
195
|
+
timeout: CLI_TIMEOUT_MS,
|
|
196
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
197
|
+
});
|
|
198
|
+
} catch {
|
|
199
|
+
continue; // spawn throw (extremely rare) — try next candidate
|
|
200
|
+
}
|
|
201
|
+
// ENOENT surfaces as error on the result object.
|
|
202
|
+
if (res.error || res.status === null || res.status !== 0) continue;
|
|
203
|
+
const raw = (res.stdout || "").trim();
|
|
204
|
+
if (raw.length === 0) continue;
|
|
205
|
+
try {
|
|
206
|
+
const parsed = JSON.parse(raw);
|
|
207
|
+
if (parsed && typeof parsed === "object") return parsed;
|
|
208
|
+
} catch {
|
|
209
|
+
// malformed JSON — try next bin (unlikely to differ, but no harm)
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// -----------------------------------------------------------------------------
|
|
216
|
+
// Rendering
|
|
217
|
+
// -----------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Group narrow entries by type (preserving canonical order), then by maturity
|
|
221
|
+
* within each type. Returns { typeOrder: string[], byType: Map<type, Map<maturity, entries[]>> }.
|
|
222
|
+
*/
|
|
223
|
+
function groupEntries(narrow) {
|
|
224
|
+
const byType = new Map();
|
|
225
|
+
const encounterOrder = [];
|
|
226
|
+
|
|
227
|
+
for (const entry of narrow) {
|
|
228
|
+
const type = entry.type || "unknown";
|
|
229
|
+
if (!byType.has(type)) {
|
|
230
|
+
byType.set(type, new Map());
|
|
231
|
+
encounterOrder.push(type);
|
|
232
|
+
}
|
|
233
|
+
const maturity = entry.maturity || "unknown";
|
|
234
|
+
const maturityMap = byType.get(type);
|
|
235
|
+
if (!maturityMap.has(maturity)) maturityMap.set(maturity, []);
|
|
236
|
+
maturityMap.get(maturity).push(entry);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Stable type order: canonical types first (when present), then anything
|
|
240
|
+
// else in encounter order.
|
|
241
|
+
const typeOrder = [];
|
|
242
|
+
for (const t of CANONICAL_TYPE_ORDER) {
|
|
243
|
+
if (byType.has(t)) typeOrder.push(t);
|
|
244
|
+
}
|
|
245
|
+
for (const t of encounterOrder) {
|
|
246
|
+
if (!CANONICAL_TYPE_ORDER.includes(t)) typeOrder.push(t);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { typeOrder, byType };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function truncateSummary(raw) {
|
|
253
|
+
const s = typeof raw === "string" ? raw : "";
|
|
254
|
+
// Collapse newlines / runs of whitespace so each entry fits one line.
|
|
255
|
+
const flat = s.replace(/\s+/g, " ").trim();
|
|
256
|
+
if (flat.length <= SUMMARY_MAX_LEN) return flat;
|
|
257
|
+
return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function formatEntryLine(entry) {
|
|
261
|
+
const id = entry.id || "(no-id)";
|
|
262
|
+
const summary = truncateSummary(entry.summary);
|
|
263
|
+
return summary.length > 0 ? ` - ${id} · ${summary}` : ` - ${id}`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Render full per-type listing — used when total narrow entries <= 30.
|
|
268
|
+
* Each entry gets one line: ` - <id> · <summary>`. Type/maturity headers
|
|
269
|
+
* group the listing.
|
|
270
|
+
*/
|
|
271
|
+
function renderFull(narrow) {
|
|
272
|
+
const { typeOrder, byType } = groupEntries(narrow);
|
|
273
|
+
const lines = [];
|
|
274
|
+
for (const type of typeOrder) {
|
|
275
|
+
const maturityMap = byType.get(type);
|
|
276
|
+
// Within each type, render maturity buckets in proven > verified > draft
|
|
277
|
+
// > unknown order so the most-trusted entries surface first.
|
|
278
|
+
const maturities = [];
|
|
279
|
+
for (const m of [MATURITY_PROVEN, MATURITY_VERIFIED, MATURITY_DRAFT]) {
|
|
280
|
+
if (maturityMap.has(m)) maturities.push(m);
|
|
281
|
+
}
|
|
282
|
+
for (const m of maturityMap.keys()) {
|
|
283
|
+
if (![MATURITY_PROVEN, MATURITY_VERIFIED, MATURITY_DRAFT].includes(m)) {
|
|
284
|
+
maturities.push(m);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
for (const maturity of maturities) {
|
|
288
|
+
lines.push(` [${type}] (${maturity}):`);
|
|
289
|
+
for (const entry of maturityMap.get(maturity)) {
|
|
290
|
+
lines.push(formatEntryLine(entry));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return lines;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Render grouped truncation — used when total narrow entries > 30. Per the
|
|
299
|
+
* task spec: proven entries get full per-line treatment; verified entries get
|
|
300
|
+
* an inline id list (no summary); draft (and unknown) buckets collapse to a
|
|
301
|
+
* count.
|
|
302
|
+
*/
|
|
303
|
+
function renderTruncated(narrow) {
|
|
304
|
+
const { typeOrder, byType } = groupEntries(narrow);
|
|
305
|
+
const lines = [];
|
|
306
|
+
for (const type of typeOrder) {
|
|
307
|
+
const maturityMap = byType.get(type);
|
|
308
|
+
|
|
309
|
+
// Proven: full per-line listing.
|
|
310
|
+
const proven = maturityMap.get(MATURITY_PROVEN);
|
|
311
|
+
if (proven && proven.length > 0) {
|
|
312
|
+
lines.push(` [${type}] proven (${proven.length}):`);
|
|
313
|
+
for (const entry of proven) {
|
|
314
|
+
lines.push(formatEntryLine(entry));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Verified: inline id list.
|
|
319
|
+
const verified = maturityMap.get(MATURITY_VERIFIED);
|
|
320
|
+
if (verified && verified.length > 0) {
|
|
321
|
+
const ids = verified.map((e) => e.id || "(no-id)").join(", ");
|
|
322
|
+
lines.push(` [${type}] verified (${verified.length}): ${ids}`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Draft + any unknown maturity: count-only.
|
|
326
|
+
let countOnly = 0;
|
|
327
|
+
for (const [maturity, entries] of maturityMap.entries()) {
|
|
328
|
+
if (maturity === MATURITY_PROVEN || maturity === MATURITY_VERIFIED) continue;
|
|
329
|
+
countOnly += entries.length;
|
|
330
|
+
}
|
|
331
|
+
if (countOnly > 0) {
|
|
332
|
+
lines.push(` [${type}] draft: ${countOnly} entries`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return lines;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Top-level rendering — picks the mode based on entry count and prepends the
|
|
340
|
+
* session-start banner + appends the revision_hash and usage hint footers.
|
|
341
|
+
*
|
|
342
|
+
* Returns an array of lines (one stderr write per line keeps the formatter
|
|
343
|
+
* trivial and testable). Returns [] when there is nothing meaningful to say
|
|
344
|
+
* (empty narrow set) so callers know to stay silent.
|
|
345
|
+
*/
|
|
346
|
+
function renderSummary(payload) {
|
|
347
|
+
const narrow = Array.isArray(payload && payload.narrow) ? payload.narrow : [];
|
|
348
|
+
if (narrow.length === 0) return [];
|
|
349
|
+
|
|
350
|
+
const truncated = narrow.length > TRUNCATION_THRESHOLD;
|
|
351
|
+
const banner = truncated
|
|
352
|
+
? `[fabric] Session start — ${narrow.length} broad-scoped knowledge entries available (truncated):`
|
|
353
|
+
: `[fabric] Session start — ${narrow.length} broad-scoped knowledge entries available:`;
|
|
354
|
+
|
|
355
|
+
const body = truncated ? renderTruncated(narrow) : renderFull(narrow);
|
|
356
|
+
|
|
357
|
+
const lines = [banner, ...body];
|
|
358
|
+
const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
|
|
359
|
+
if (revHash !== null && revHash.length > 0) {
|
|
360
|
+
lines.push(` revision_hash: ${revHash}`);
|
|
361
|
+
}
|
|
362
|
+
lines.push(" Use `fab_get_knowledge_sections` to fetch full content.");
|
|
363
|
+
return lines;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// -----------------------------------------------------------------------------
|
|
367
|
+
// Main entry — invoked both as a CLI (require.main === module) and in-process
|
|
368
|
+
// by tests. Wraps the entire flow in try/catch: ANY error → silent exit 0.
|
|
369
|
+
// -----------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
function main(env, stdio) {
|
|
372
|
+
try {
|
|
373
|
+
const cwd = (env && env.cwd) || process.cwd();
|
|
374
|
+
const err = (stdio && stdio.stderr) || process.stderr;
|
|
375
|
+
|
|
376
|
+
// Test seam: env.payload short-circuits the CLI spawn so unit tests can
|
|
377
|
+
// feed canned plan-context-hint JSON without depending on a built CLI.
|
|
378
|
+
const payload =
|
|
379
|
+
env && env.payload !== undefined ? env.payload : invokePlanContextHint(cwd);
|
|
380
|
+
if (payload === null || payload === undefined) return; // silent
|
|
381
|
+
|
|
382
|
+
// rc.7 T1: sentinel-override gate. When `.fabric/.import-requested` is
|
|
383
|
+
// present, the import-recommendation banner ALWAYS surfaces regardless
|
|
384
|
+
// of revision_hash equality — the user asked for it on init Y-confirm
|
|
385
|
+
// and the fabric-import Skill is responsible for clearing the sentinel
|
|
386
|
+
// when its Phase 3 completes. The override sits BEFORE the gate so the
|
|
387
|
+
// revision_hash cache is not updated either (we want the
|
|
388
|
+
// recommendation to keep surfacing on subsequent boots until the user
|
|
389
|
+
// actually runs import).
|
|
390
|
+
const sentinelPresent = isImportRequestedSentinelPresent(cwd);
|
|
391
|
+
|
|
392
|
+
// rc.7 T8: revision_hash gate. If the CLI payload carries a stable
|
|
393
|
+
// revision_hash and it matches the previously-emitted hash recorded in
|
|
394
|
+
// the sidecar, the knowledge graph is unchanged since last session →
|
|
395
|
+
// silent exit 0 (no re-dump). The sentinel override above takes
|
|
396
|
+
// precedence and bypasses this gate.
|
|
397
|
+
const currentHash =
|
|
398
|
+
typeof payload.revision_hash === "string" ? payload.revision_hash : "";
|
|
399
|
+
if (!sentinelPresent && currentHash.length > 0) {
|
|
400
|
+
const lastHash = readSessionStartLastHash(cwd);
|
|
401
|
+
if (lastHash !== null && lastHash === currentHash) {
|
|
402
|
+
// Same canonical graph as last session — banner blindness mitigation.
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const lines = renderSummary(payload);
|
|
408
|
+
|
|
409
|
+
// rc.7 T1: when the sentinel is present, append the import-recommendation
|
|
410
|
+
// banner. This line is appended whether or not the broad summary had
|
|
411
|
+
// entries — even an empty knowledge graph benefits from the prompt.
|
|
412
|
+
if (sentinelPresent) {
|
|
413
|
+
lines.push(
|
|
414
|
+
" 📋 Fabric: 检测到 fabric init 提示要回灌知识 — 是否调 /fabric-import 从 git 历史和现有文档抽取?",
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (lines.length === 0) return; // empty narrow set + no sentinel — silent
|
|
419
|
+
|
|
420
|
+
for (const line of lines) {
|
|
421
|
+
err.write(`${line}\n`);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Update sidecar AFTER successful emit. We only persist the hash when
|
|
425
|
+
// the gate actually let the dump through (i.e. when not sentinel-only).
|
|
426
|
+
// Sentinel-only emits don't bump the cache so the next non-sentinel
|
|
427
|
+
// SessionStart still gets to compare the prior session's true hash.
|
|
428
|
+
if (!sentinelPresent && currentHash.length > 0) {
|
|
429
|
+
writeSessionStartLastHash(cwd, currentHash);
|
|
430
|
+
}
|
|
431
|
+
} catch {
|
|
432
|
+
// Silent — never block session start on hook failure.
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
module.exports = {
|
|
437
|
+
main,
|
|
438
|
+
invokePlanContextHint,
|
|
439
|
+
groupEntries,
|
|
440
|
+
renderFull,
|
|
441
|
+
renderTruncated,
|
|
442
|
+
renderSummary,
|
|
443
|
+
truncateSummary,
|
|
444
|
+
// rc.7 T8: revision_hash gating sidecar helpers (exported for unit testing).
|
|
445
|
+
readSessionStartLastHash,
|
|
446
|
+
writeSessionStartLastHash,
|
|
447
|
+
// rc.7 T1: sentinel-override pickup (exported for unit testing).
|
|
448
|
+
isImportRequestedSentinelPresent,
|
|
449
|
+
CONSTANTS: {
|
|
450
|
+
TRUNCATION_THRESHOLD,
|
|
451
|
+
CLI_TIMEOUT_MS,
|
|
452
|
+
SUMMARY_MAX_LEN,
|
|
453
|
+
CANONICAL_TYPE_ORDER,
|
|
454
|
+
MATURITY_PROVEN,
|
|
455
|
+
MATURITY_VERIFIED,
|
|
456
|
+
MATURITY_DRAFT,
|
|
457
|
+
SESSIONSTART_HASH_CACHE_FILE,
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
if (require.main === module) {
|
|
462
|
+
main({ cwd: process.cwd() }, { stderr: process.stderr });
|
|
463
|
+
process.exit(0);
|
|
464
|
+
}
|