@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.
- package/LICENSE +21 -0
- package/README.md +6 -5
- package/dist/chunk-BATF4PEJ.js +361 -0
- package/dist/{chunk-OBQU6NHO.js → chunk-COI5VDFU.js} +0 -18
- package/dist/chunk-D25XJ4BC.js +880 -0
- package/dist/chunk-MF3OTILQ.js +544 -0
- package/dist/chunk-PWLW3B57.js +18 -0
- package/dist/config-XJIPZNUP.js +13 -0
- package/dist/doctor-EJDSEJSS.js +810 -0
- package/dist/index.js +15 -8
- package/dist/{init-BIRSIOXO.js → install-EKWMFLUU.js} +622 -711
- package/dist/metrics-ACEQFPDU.js +122 -0
- package/dist/onboard-coverage-MFCAEBDO.js +220 -0
- package/dist/{plan-context-hint-QMUPAXIB.js → plan-context-hint-FC6P3WFE.js} +34 -28
- package/dist/uninstall-MH7ZIB6M.js +1064 -0
- package/package.json +30 -5
- package/templates/hooks/cite-policy-evict.cjs +231 -0
- package/templates/hooks/configs/README.md +29 -6
- package/templates/hooks/configs/claude-code.json +14 -3
- package/templates/hooks/configs/codex-hooks.json +6 -3
- package/templates/hooks/configs/cursor-hooks.json +8 -10
- package/templates/hooks/fabric-hint.cjs +833 -105
- package/templates/hooks/knowledge-hint-broad.cjs +509 -135
- package/templates/hooks/knowledge-hint-narrow.cjs +791 -26
- package/templates/hooks/lib/banner-i18n.cjs +309 -0
- package/templates/hooks/lib/cite-contract-reminder.cjs +173 -0
- package/templates/hooks/lib/cite-line-parser.cjs +158 -0
- package/templates/hooks/lib/client-adapter.cjs +106 -0
- package/templates/hooks/lib/config-cache.cjs +107 -0
- package/templates/hooks/lib/state-store.cjs +84 -0
- package/templates/hooks/lib/summary-fallback.cjs +210 -0
- package/templates/skills/fabric-archive/SKILL.md +93 -419
- package/templates/skills/fabric-archive/ref/dry-run-scope.md +16 -0
- package/templates/skills/fabric-archive/ref/e5-cron-recap.md +58 -0
- package/templates/skills/fabric-archive/ref/i18n-policy.md +86 -0
- package/templates/skills/fabric-archive/ref/phase-0-range-resolution.md +156 -0
- package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +218 -0
- package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +62 -0
- package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +68 -0
- package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +108 -0
- package/templates/skills/fabric-archive/ref/phase-3-classify.md +63 -0
- package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +78 -0
- package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +89 -0
- package/templates/skills/fabric-archive/ref/rc-history.md +38 -0
- package/templates/skills/fabric-archive/ref/worked-examples.md +78 -0
- package/templates/skills/fabric-import/SKILL.md +75 -516
- package/templates/skills/fabric-import/ref/checkpoint-state.md +85 -0
- package/templates/skills/fabric-import/ref/i18n-policy.md +79 -0
- package/templates/skills/fabric-import/ref/output-contract.md +61 -0
- package/templates/skills/fabric-import/ref/phase-2-mining.md +213 -0
- package/templates/skills/fabric-import/ref/phase-3-dedup.md +75 -0
- package/templates/skills/fabric-import/ref/state-recovery.md +57 -0
- package/templates/skills/fabric-import/ref/worked-examples.md +127 -0
- package/templates/skills/fabric-review/SKILL.md +86 -284
- package/templates/skills/fabric-review/ref/askuserquestion-policy.md +66 -0
- package/templates/skills/fabric-review/ref/i18n-policy.md +111 -0
- package/templates/skills/fabric-review/ref/modify-flow.md +103 -0
- package/templates/skills/fabric-review/ref/output-contract.md +58 -0
- package/templates/skills/fabric-review/ref/per-mode-flows.md +155 -0
- package/templates/skills/fabric-review/ref/semantic-check.md +26 -0
- package/templates/skills/fabric-review/ref/worked-examples.md +95 -0
- package/templates/skills/lib/shared-policy.md +69 -0
- package/dist/chunk-6ICJICVU.js +0 -10
- package/dist/chunk-74SZWYPH.js +0 -658
- package/dist/chunk-EYIDD2YS.js +0 -1000
- package/dist/doctor-T7JWODKG.js +0 -282
- package/dist/hooks-Y74Y5LQS.js +0 -12
- package/dist/scan-LMK3UCWL.js +0 -22
- package/dist/serve-H554BHLG.js +0 -124
- package/templates/agents-md/AGENTS.md.template +0 -59
- package/templates/bootstrap/CLAUDE.md +0 -8
- package/templates/bootstrap/codex-AGENTS-header.md +0 -6
- package/templates/bootstrap/cursor-fabric-bootstrap.mdc +0 -10
|
@@ -47,86 +47,255 @@
|
|
|
47
47
|
*/
|
|
48
48
|
|
|
49
49
|
const { spawnSync } = require("node:child_process");
|
|
50
|
-
const { existsSync,
|
|
51
|
-
const {
|
|
50
|
+
const { existsSync, readdirSync, readFileSync } = require("node:fs");
|
|
51
|
+
const { join } = require("node:path");
|
|
52
|
+
|
|
53
|
+
// rc.16 TASK-003: shared banner-i18n lib (resolves fabric_language config and
|
|
54
|
+
// renders localized banner text). Mirror of the wiring in fabric-hint.cjs
|
|
55
|
+
// (TASK-002). Variant is resolved ONCE per main() invocation via
|
|
56
|
+
// readFabricLanguage(cwd) and threaded into renderBanner — no fs in render path.
|
|
57
|
+
const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
|
|
58
|
+
const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
|
|
59
|
+
// v2.0.0-rc.37 NEW-19: shared fabric-config reader + sidecar I/O. Replaces the
|
|
60
|
+
// five per-key readFileSync+parse config readers (one parse per fire now) and
|
|
61
|
+
// the bespoke last-emit sidecar helpers. The L78 "refactor into lib/ if a
|
|
62
|
+
// third hook needs it" note is now realised.
|
|
63
|
+
const {
|
|
64
|
+
readConfigNumber,
|
|
65
|
+
readConfigBoolean,
|
|
66
|
+
} = require("./lib/config-cache.cjs");
|
|
67
|
+
const { readTextState, writeTextState } = require("./lib/state-store.cjs");
|
|
68
|
+
// v2.0.0-rc.37 NEW-30: shared client detection (replaces the inline
|
|
69
|
+
// CLAUDE_PROJECT_DIR single-bit check below).
|
|
70
|
+
const { isClaudeCode } = require("./lib/client-adapter.cjs");
|
|
52
71
|
|
|
53
72
|
// -----------------------------------------------------------------------------
|
|
54
|
-
// rc.
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
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.
|
|
73
|
+
// rc.12: SessionStart broad-menu is now unconditionally emitted on every
|
|
74
|
+
// SessionStart fire (matching Skill-style progressive disclosure). Prior
|
|
75
|
+
// versions (rc.5-rc.11) wrote `.fabric/.cache/sessionstart-last-hash` as a
|
|
76
|
+
// revision_hash cooldown sidecar to suppress re-emission on unchanged
|
|
77
|
+
// knowledge graphs; that gate was removed in rc.12. Orphaned sidecar files
|
|
78
|
+
// on existing dogfood repos are harmless dead state and are intentionally
|
|
79
|
+
// NOT cleaned up (zero-user clean-slate — no migration logic needed).
|
|
75
80
|
// -----------------------------------------------------------------------------
|
|
76
81
|
|
|
77
82
|
const FABRIC_DIR_REL = ".fabric";
|
|
78
|
-
|
|
83
|
+
|
|
84
|
+
// rc.8 underseed self-check constants (mirror fabric-hint.cjs ~line 76 / 83).
|
|
85
|
+
// Intentionally duplicated inline — hooks are independent .cjs files and
|
|
86
|
+
// cannot `require` each other. If a third hook ever needs the same logic,
|
|
87
|
+
// refactor into packages/cli/templates/hooks/lib/. Keep these values in sync
|
|
88
|
+
// with packages/cli/templates/hooks/fabric-hint.cjs.
|
|
89
|
+
const AGENTS_META_FILE = "agents.meta.json";
|
|
90
|
+
const IMPORT_STATE_FILE = ".import-state.json";
|
|
91
|
+
const KNOWLEDGE_CANONICAL_TYPES = [
|
|
92
|
+
"decisions",
|
|
93
|
+
"pitfalls",
|
|
94
|
+
"guidelines",
|
|
95
|
+
"models",
|
|
96
|
+
"processes",
|
|
97
|
+
];
|
|
98
|
+
const DEFAULT_UNDERSEED_NODE_THRESHOLD = 10;
|
|
99
|
+
|
|
100
|
+
// v2.0.0-rc.33 W2-1 (P0-9): TopK upper bound on broad-scoped entries surfaced
|
|
101
|
+
// per SessionStart fire. Keeps the banner inside ~1 screenful so the agent
|
|
102
|
+
// actually reads the top-priority entries instead of triaging a wall of text.
|
|
103
|
+
// Overridable via fabric-config.json#hint_broad_top_k (range 1..50).
|
|
104
|
+
const DEFAULT_HINT_BROAD_TOP_K = 8;
|
|
105
|
+
|
|
106
|
+
// v2.0.0-rc.33 W2-5 (P1-8): cooldown (in hours) between broad-hint re-emits.
|
|
107
|
+
// Default 0 preserves rc.32 behavior — every SessionStart re-fires the banner.
|
|
108
|
+
// Cache key uses a separate sidecar from the fabric-hint Signal A/B/C cache
|
|
109
|
+
// so the two cooldowns don't interfere.
|
|
110
|
+
const DEFAULT_HINT_BROAD_COOLDOWN_HOURS = 0;
|
|
111
|
+
const MS_PER_HOUR = 60 * 60 * 1000;
|
|
112
|
+
// v2.0.0-rc.37 NEW-19: state-store resolves this basename under .fabric/.cache/.
|
|
113
|
+
const HINT_BROAD_LAST_EMIT_FILE_NAME = "knowledge-hint-broad-last-emit";
|
|
114
|
+
|
|
115
|
+
// v2.0.0-rc.33 W2-6 (P0-7): when true, emit banner as
|
|
116
|
+
// hookSpecificOutput.additionalContext JSON on stdout (Claude Code PreToolUse
|
|
117
|
+
// contract) so the model receives the reminder in-context. Stderr remains the
|
|
118
|
+
// human-facing channel for logs / breadcrumbs.
|
|
119
|
+
const DEFAULT_HINT_REMINDER_TO_CONTEXT = true;
|
|
120
|
+
|
|
121
|
+
// -----------------------------------------------------------------------------
|
|
122
|
+
// rc.8 underseed self-check helpers.
|
|
123
|
+
//
|
|
124
|
+
// These three helpers (countCanonicalNodes / readUnderseedThreshold /
|
|
125
|
+
// isImportTouched) are inline copies of the equivalent logic in
|
|
126
|
+
// packages/cli/templates/hooks/fabric-hint.cjs (~lines 218 / 749). Hooks
|
|
127
|
+
// cannot `require` each other (each .cjs is rendered as a standalone template
|
|
128
|
+
// at init time), so duplication is the documented convention. Cross-reference:
|
|
129
|
+
// keep both copies in sync; if a third hook needs the same logic, extract to
|
|
130
|
+
// packages/cli/templates/hooks/lib/.
|
|
131
|
+
// -----------------------------------------------------------------------------
|
|
79
132
|
|
|
80
133
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
134
|
+
* Count canonical knowledge entries across the five canonical type subdirs
|
|
135
|
+
* (decisions / pitfalls / guidelines / models / processes). Pending entries
|
|
136
|
+
* are NOT counted — they are proposals, not seeded knowledge.
|
|
84
137
|
*
|
|
85
|
-
*
|
|
138
|
+
* Returns the integer count. ENOENT / unreadable subdir → silently treated as
|
|
139
|
+
* zero (preserves never-block-on-failure invariant). Filters on `.md` suffix
|
|
140
|
+
* only; the more-precise canonical filename pattern check is owned by
|
|
141
|
+
* doctor.ts (the hook is a coarse signal, not a lint).
|
|
86
142
|
*/
|
|
87
|
-
function
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const raw = readFileSync(p, "utf8").trim();
|
|
92
|
-
return raw.length > 0 ? raw : null;
|
|
93
|
-
} catch {
|
|
94
|
-
return null;
|
|
143
|
+
function countCanonicalNodes(projectRoot) {
|
|
144
|
+
const knowledgeRoot = join(projectRoot, FABRIC_DIR_REL, "knowledge");
|
|
145
|
+
if (!existsSync(knowledgeRoot)) {
|
|
146
|
+
return 0;
|
|
95
147
|
}
|
|
148
|
+
let count = 0;
|
|
149
|
+
for (const type of KNOWLEDGE_CANONICAL_TYPES) {
|
|
150
|
+
const typeDir = join(knowledgeRoot, type);
|
|
151
|
+
if (!existsSync(typeDir)) continue;
|
|
152
|
+
let entries;
|
|
153
|
+
try {
|
|
154
|
+
entries = readdirSync(typeDir);
|
|
155
|
+
} catch {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const entry of entries) {
|
|
159
|
+
if (entry.endsWith(".md")) {
|
|
160
|
+
count += 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return count;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the underseed-node threshold from .fabric/fabric-config.json
|
|
169
|
+
* (underseed_node_threshold), falling back to DEFAULT_UNDERSEED_NODE_THRESHOLD.
|
|
170
|
+
* Any read/parse failure → default (never block on config errors).
|
|
171
|
+
*/
|
|
172
|
+
function readUnderseedThreshold(projectRoot) {
|
|
173
|
+
// > 0 guard via min: Number.MIN_VALUE (any positive). config-cache returns
|
|
174
|
+
// the parsed number when finite & in-range, else the default.
|
|
175
|
+
return readConfigNumber(projectRoot, "underseed_node_threshold", DEFAULT_UNDERSEED_NODE_THRESHOLD, {
|
|
176
|
+
min: Number.MIN_VALUE,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* v2.0.0-rc.33 W2-1: resolve hint_broad_top_k from fabric-config.json. Slices
|
|
182
|
+
* the broad entry list to TopK before group/truncation render. Validates the
|
|
183
|
+
* schema's 1..50 range inline so a malformed config silently falls back.
|
|
184
|
+
*/
|
|
185
|
+
function readBroadTopK(projectRoot) {
|
|
186
|
+
return readConfigNumber(projectRoot, "hint_broad_top_k", DEFAULT_HINT_BROAD_TOP_K, {
|
|
187
|
+
min: 1,
|
|
188
|
+
max: 50,
|
|
189
|
+
floor: true,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* v2.0.0-rc.33 W2-5: resolve hint_broad_cooldown_hours. Schema clamps 0..168;
|
|
195
|
+
* 0 means "no cooldown" (re-emit on every SessionStart, rc.32 behavior).
|
|
196
|
+
*/
|
|
197
|
+
function readBroadCooldownHours(projectRoot) {
|
|
198
|
+
return readConfigNumber(projectRoot, "hint_broad_cooldown_hours", DEFAULT_HINT_BROAD_COOLDOWN_HOURS, {
|
|
199
|
+
min: 0,
|
|
200
|
+
max: 168,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* v2.0.0-rc.33 W2-6: resolve hint_reminder_to_context. Boolean flag — when
|
|
206
|
+
* true (default) the hook writes a Claude-Code-shaped JSON envelope to stdout
|
|
207
|
+
* carrying the banner under hookSpecificOutput.additionalContext so the model
|
|
208
|
+
* receives the reminder in-context. Stderr stays informational either way.
|
|
209
|
+
*/
|
|
210
|
+
function readReminderToContext(projectRoot) {
|
|
211
|
+
return readConfigBoolean(projectRoot, "hint_reminder_to_context", DEFAULT_HINT_REMINDER_TO_CONTEXT);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* v2.0.0-rc.33 W2-5: read/write the broad-hint last-emit timestamp sidecar.
|
|
216
|
+
* Distinct from fabric-hint's shown-cache so signal cooldowns stay isolated.
|
|
217
|
+
* Returns epoch ms or null when missing/unreadable.
|
|
218
|
+
*/
|
|
219
|
+
function readBroadLastEmit(projectRoot) {
|
|
220
|
+
const raw = readTextState(projectRoot, HINT_BROAD_LAST_EMIT_FILE_NAME);
|
|
221
|
+
if (raw === null || raw.length === 0) return null;
|
|
222
|
+
const asNum = Number(raw);
|
|
223
|
+
if (Number.isFinite(asNum) && asNum > 0) return asNum;
|
|
224
|
+
const ms = Date.parse(raw);
|
|
225
|
+
if (Number.isFinite(ms)) return ms;
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function writeBroadLastEmit(projectRoot, nowMs) {
|
|
230
|
+
// Silent — sidecar failure must never block session start.
|
|
231
|
+
writeTextState(projectRoot, HINT_BROAD_LAST_EMIT_FILE_NAME, String(nowMs));
|
|
96
232
|
}
|
|
97
233
|
|
|
98
234
|
/**
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
235
|
+
* Classify the on-disk import lifecycle by reading
|
|
236
|
+
* `.fabric/.import-state.json`. Returns one of:
|
|
237
|
+
* - 'absent' — state file missing → user has NEVER started import
|
|
238
|
+
* - 'in_progress' — file present, phase is anything that is not 'complete'
|
|
239
|
+
* (covers 'P1-done', 'P2-done', 'phase 1', 'in_progress',
|
|
240
|
+
* '1', and any other live-import marker)
|
|
241
|
+
* - 'complete' — file present and phase === 'complete'
|
|
242
|
+
* - 'error' — file present but unreadable / unparseable JSON
|
|
243
|
+
*
|
|
244
|
+
* Recommendation rule (see shouldRecommendImport): only 'absent' triggers a
|
|
245
|
+
* banner — both 'in_progress' (user is actively importing) and 'complete'
|
|
246
|
+
* (user already imported) suppress the banner. 'error' also suppresses
|
|
247
|
+
* (defensive: do not nag when state is unreadable, the user has clearly
|
|
248
|
+
* touched the file).
|
|
103
249
|
*/
|
|
104
|
-
function
|
|
250
|
+
function isImportTouched(projectRoot) {
|
|
251
|
+
const statePath = join(projectRoot, FABRIC_DIR_REL, IMPORT_STATE_FILE);
|
|
252
|
+
if (!existsSync(statePath)) return "absent";
|
|
253
|
+
let raw;
|
|
254
|
+
try {
|
|
255
|
+
raw = readFileSync(statePath, "utf8");
|
|
256
|
+
} catch {
|
|
257
|
+
return "error";
|
|
258
|
+
}
|
|
259
|
+
let parsed;
|
|
105
260
|
try {
|
|
106
|
-
|
|
107
|
-
const p = join(projectRoot, SESSIONSTART_HASH_CACHE_FILE);
|
|
108
|
-
mkdirSync(dirname(p), { recursive: true });
|
|
109
|
-
writeFileSync(p, hash, "utf8");
|
|
261
|
+
parsed = JSON.parse(raw);
|
|
110
262
|
} catch {
|
|
111
|
-
|
|
263
|
+
return "error";
|
|
112
264
|
}
|
|
265
|
+
if (!parsed || typeof parsed !== "object") return "error";
|
|
266
|
+
return parsed.phase === "complete" ? "complete" : "in_progress";
|
|
113
267
|
}
|
|
114
268
|
|
|
115
269
|
/**
|
|
116
|
-
* rc.
|
|
117
|
-
*
|
|
118
|
-
* the user wants the next SessionStart to recommend `fabric-import`.
|
|
270
|
+
* rc.8 underseed self-check: determine whether the SessionStart hook should
|
|
271
|
+
* surface the one-line `/fabric-import` recommendation banner.
|
|
119
272
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
273
|
+
* Three-condition truth table (ALL must hold to return true):
|
|
274
|
+
* 1. `.fabric/agents.meta.json` exists
|
|
275
|
+
* (workspace has been `fabric init`-ed; otherwise the recommendation
|
|
276
|
+
* is meaningless — `fabric-import` requires init's baseline scan).
|
|
277
|
+
* 2. countCanonicalNodes(cwd) < readUnderseedThreshold(cwd)
|
|
278
|
+
* (knowledge graph is sparse — import would meaningfully enrich it).
|
|
279
|
+
* 3. isImportTouched(cwd) === 'absent'
|
|
280
|
+
* (.import-state.json is missing entirely; user has neither started
|
|
281
|
+
* nor completed an import. ANY phase value — including 'in_progress'
|
|
282
|
+
* and 'complete' — returns false because the user has either started
|
|
283
|
+
* or finished.)
|
|
124
284
|
*
|
|
125
|
-
* Best-effort
|
|
285
|
+
* Best-effort: any unexpected error → return false (do not nag on faults).
|
|
126
286
|
*/
|
|
127
|
-
function
|
|
287
|
+
function shouldRecommendImport(projectRoot) {
|
|
128
288
|
try {
|
|
129
|
-
|
|
289
|
+
const metaPath = join(projectRoot, FABRIC_DIR_REL, AGENTS_META_FILE);
|
|
290
|
+
if (!existsSync(metaPath)) return false;
|
|
291
|
+
|
|
292
|
+
const threshold = readUnderseedThreshold(projectRoot);
|
|
293
|
+
const nodeCount = countCanonicalNodes(projectRoot);
|
|
294
|
+
if (nodeCount >= threshold) return false;
|
|
295
|
+
|
|
296
|
+
if (isImportTouched(projectRoot) !== "absent") return false;
|
|
297
|
+
|
|
298
|
+
return true;
|
|
130
299
|
} catch {
|
|
131
300
|
return false;
|
|
132
301
|
}
|
|
@@ -136,12 +305,16 @@ function isImportRequestedSentinelPresent(projectRoot) {
|
|
|
136
305
|
// CONSTANTS
|
|
137
306
|
// -----------------------------------------------------------------------------
|
|
138
307
|
|
|
139
|
-
// Per-type truncation triggers when total
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
|
|
308
|
+
// Per-type truncation triggers when total broad-scope entries > N.
|
|
309
|
+
// v2.0.0-rc.29 TASK-007 (BUG-F1): lowered from 30 → 12. SessionStart hint
|
|
310
|
+
// should bias toward "is there anything relevant?" rather than "exhaustive
|
|
311
|
+
// index" — at 30, the banner consumed several terminal screens on
|
|
312
|
+
// well-seeded repos and operators reported scroll fatigue. 12 keeps a
|
|
313
|
+
// dense-enough scan (still fits "top hits per type" in 1-2 screenfuls)
|
|
314
|
+
// without prompting the user to mentally truncate themselves. The constant
|
|
315
|
+
// stays a stable rendering boundary; downstream consumers (banner-i18n.cjs,
|
|
316
|
+
// truncation summary lines) consume it as a single source of truth.
|
|
317
|
+
const TRUNCATION_THRESHOLD = 12;
|
|
145
318
|
|
|
146
319
|
// `fabric plan-context-hint` is a thin wrapper over planContext(); on a
|
|
147
320
|
// well-seeded repo it returns in ~100ms. Two-second cap is defensive — any
|
|
@@ -150,8 +323,17 @@ const CLI_TIMEOUT_MS = 2000;
|
|
|
150
323
|
|
|
151
324
|
// Maximum summary length per entry. Keeps each line bounded so stderr does
|
|
152
325
|
// not blow up terminal width with multi-paragraph summaries from sloppy
|
|
153
|
-
// pending entries. Truncation appends an ellipsis.
|
|
154
|
-
|
|
326
|
+
// pending entries. Truncation appends an ellipsis. v2.0.0-rc.33 W4-A3:
|
|
327
|
+
// `hint_summary_max_len` in fabric-config overrides this default (range 40..240).
|
|
328
|
+
const DEFAULT_SUMMARY_MAX_LEN = 80;
|
|
329
|
+
|
|
330
|
+
function readSummaryMaxLen(projectRoot) {
|
|
331
|
+
return readConfigNumber(projectRoot, "hint_summary_max_len", DEFAULT_SUMMARY_MAX_LEN, {
|
|
332
|
+
min: 40,
|
|
333
|
+
max: 240,
|
|
334
|
+
floor: true,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
155
337
|
|
|
156
338
|
// Canonical type order — render groups in this sequence so output is stable
|
|
157
339
|
// across runs (Object.keys iteration order is insertion order, but the JSON
|
|
@@ -172,6 +354,13 @@ const MATURITY_PROVEN = "proven";
|
|
|
172
354
|
const MATURITY_VERIFIED = "verified";
|
|
173
355
|
const MATURITY_DRAFT = "draft";
|
|
174
356
|
|
|
357
|
+
// rc.8 underseed self-check banner: single line, emoji-prefixed (cf.
|
|
358
|
+
// fabric-hint.cjs Signal C `📋 Fabric:`). rc.16 TASK-003 routed the literal
|
|
359
|
+
// through the banner-i18n lib (key: 'broadImportBanner') — see main() below
|
|
360
|
+
// for the renderBanner call site. Substring contracts preserved across all
|
|
361
|
+
// variants: leading two-space indent, `📋 Fabric:` prefix, `/fabric-import`
|
|
362
|
+
// verbatim token (asserted by knowledge-hint-broad.test.ts).
|
|
363
|
+
|
|
175
364
|
// -----------------------------------------------------------------------------
|
|
176
365
|
// CLI invocation
|
|
177
366
|
// -----------------------------------------------------------------------------
|
|
@@ -180,12 +369,16 @@ const MATURITY_DRAFT = "draft";
|
|
|
180
369
|
* Spawn `fabric plan-context-hint --all` and return parsed JSON. Returns
|
|
181
370
|
* null on any failure (ENOENT, non-zero exit, malformed JSON). Never throws.
|
|
182
371
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
* return null — the hook stays silent rather than nagging about install state.
|
|
372
|
+
* If `fabric` is not on PATH, return null — the hook stays silent rather
|
|
373
|
+
* than nagging about install state.
|
|
186
374
|
*/
|
|
187
375
|
function invokePlanContextHint(cwd) {
|
|
188
|
-
const candidates = ["fabric"
|
|
376
|
+
const candidates = ["fabric"];
|
|
377
|
+
// rc.31 NEW-6: capture the last meaningful failure so we can surface it on
|
|
378
|
+
// stderr before fail-open. Without this, hook silently swallows backend
|
|
379
|
+
// crashes (e.g. agents_meta_invalid → plan-context-hint exits with stderr
|
|
380
|
+
// payload and the AI / user never sees KB chain is dead).
|
|
381
|
+
let lastFailure = null;
|
|
189
382
|
for (const bin of candidates) {
|
|
190
383
|
let res;
|
|
191
384
|
try {
|
|
@@ -198,17 +391,37 @@ function invokePlanContextHint(cwd) {
|
|
|
198
391
|
} catch {
|
|
199
392
|
continue; // spawn throw (extremely rare) — try next candidate
|
|
200
393
|
}
|
|
201
|
-
// ENOENT surfaces as error on the result object.
|
|
202
|
-
|
|
394
|
+
// ENOENT surfaces as error on the result object. Skip silently for ENOENT
|
|
395
|
+
// (bin not installed is the only legitimate reason to bail).
|
|
396
|
+
if (res.error) {
|
|
397
|
+
if (res.error.code !== "ENOENT") {
|
|
398
|
+
lastFailure = { bin, reason: String(res.error.message || res.error.code || res.error) };
|
|
399
|
+
}
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (res.status === null || res.status !== 0) {
|
|
403
|
+
const stderrSnip = (res.stderr || "").trim().slice(0, 240);
|
|
404
|
+
if (stderrSnip.length > 0) {
|
|
405
|
+
lastFailure = { bin, reason: stderrSnip };
|
|
406
|
+
}
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
203
409
|
const raw = (res.stdout || "").trim();
|
|
204
410
|
if (raw.length === 0) continue;
|
|
205
411
|
try {
|
|
206
412
|
const parsed = JSON.parse(raw);
|
|
207
413
|
if (parsed && typeof parsed === "object") return parsed;
|
|
208
|
-
} catch {
|
|
209
|
-
|
|
414
|
+
} catch (err) {
|
|
415
|
+
lastFailure = { bin, reason: `malformed JSON from plan-context-hint: ${String(err && err.message || err)}` };
|
|
210
416
|
}
|
|
211
417
|
}
|
|
418
|
+
if (lastFailure !== null) {
|
|
419
|
+
// Single warning line — never throws, never blocks the hook. Lets users /
|
|
420
|
+
// AI notice that the KB chain is degraded instead of being silently empty.
|
|
421
|
+
process.stderr.write(
|
|
422
|
+
`[fabric-hint] plan-context-hint (${lastFailure.bin}) failed: ${lastFailure.reason.replace(/\n/g, " ")}\n`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
212
425
|
return null;
|
|
213
426
|
}
|
|
214
427
|
|
|
@@ -249,17 +462,21 @@ function groupEntries(narrow) {
|
|
|
249
462
|
return { typeOrder, byType };
|
|
250
463
|
}
|
|
251
464
|
|
|
252
|
-
|
|
465
|
+
// v2.0.0-rc.33 W4-A3: maxLen is now caller-supplied (sourced from
|
|
466
|
+
// fabric-config#hint_summary_max_len in main; tests + ad-hoc callers may
|
|
467
|
+
// omit to fall back to DEFAULT_SUMMARY_MAX_LEN).
|
|
468
|
+
function truncateSummary(raw, maxLen) {
|
|
253
469
|
const s = typeof raw === "string" ? raw : "";
|
|
254
470
|
// Collapse newlines / runs of whitespace so each entry fits one line.
|
|
255
471
|
const flat = s.replace(/\s+/g, " ").trim();
|
|
256
|
-
|
|
257
|
-
|
|
472
|
+
const cap = typeof maxLen === "number" && maxLen > 0 ? maxLen : DEFAULT_SUMMARY_MAX_LEN;
|
|
473
|
+
if (flat.length <= cap) return flat;
|
|
474
|
+
return `${flat.slice(0, cap - 1)}…`;
|
|
258
475
|
}
|
|
259
476
|
|
|
260
|
-
function formatEntryLine(entry) {
|
|
477
|
+
function formatEntryLine(entry, maxLen) {
|
|
261
478
|
const id = entry.id || "(no-id)";
|
|
262
|
-
const summary = truncateSummary(entry.summary);
|
|
479
|
+
const summary = truncateSummary(entry.summary, maxLen);
|
|
263
480
|
return summary.length > 0 ? ` - ${id} · ${summary}` : ` - ${id}`;
|
|
264
481
|
}
|
|
265
482
|
|
|
@@ -268,7 +485,7 @@ function formatEntryLine(entry) {
|
|
|
268
485
|
* Each entry gets one line: ` - <id> · <summary>`. Type/maturity headers
|
|
269
486
|
* group the listing.
|
|
270
487
|
*/
|
|
271
|
-
function renderFull(narrow) {
|
|
488
|
+
function renderFull(narrow, maxLen) {
|
|
272
489
|
const { typeOrder, byType } = groupEntries(narrow);
|
|
273
490
|
const lines = [];
|
|
274
491
|
for (const type of typeOrder) {
|
|
@@ -287,7 +504,7 @@ function renderFull(narrow) {
|
|
|
287
504
|
for (const maturity of maturities) {
|
|
288
505
|
lines.push(` [${type}] (${maturity}):`);
|
|
289
506
|
for (const entry of maturityMap.get(maturity)) {
|
|
290
|
-
lines.push(formatEntryLine(entry));
|
|
507
|
+
lines.push(formatEntryLine(entry, maxLen));
|
|
291
508
|
}
|
|
292
509
|
}
|
|
293
510
|
}
|
|
@@ -300,7 +517,7 @@ function renderFull(narrow) {
|
|
|
300
517
|
* an inline id list (no summary); draft (and unknown) buckets collapse to a
|
|
301
518
|
* count.
|
|
302
519
|
*/
|
|
303
|
-
function renderTruncated(narrow) {
|
|
520
|
+
function renderTruncated(narrow, maxLen) {
|
|
304
521
|
const { typeOrder, byType } = groupEntries(narrow);
|
|
305
522
|
const lines = [];
|
|
306
523
|
for (const type of typeOrder) {
|
|
@@ -311,7 +528,7 @@ function renderTruncated(narrow) {
|
|
|
311
528
|
if (proven && proven.length > 0) {
|
|
312
529
|
lines.push(` [${type}] proven (${proven.length}):`);
|
|
313
530
|
for (const entry of proven) {
|
|
314
|
-
lines.push(formatEntryLine(entry));
|
|
531
|
+
lines.push(formatEntryLine(entry, maxLen));
|
|
315
532
|
}
|
|
316
533
|
}
|
|
317
534
|
|
|
@@ -341,24 +558,83 @@ function renderTruncated(narrow) {
|
|
|
341
558
|
*
|
|
342
559
|
* Returns an array of lines (one stderr write per line keeps the formatter
|
|
343
560
|
* trivial and testable). Returns [] when there is nothing meaningful to say
|
|
344
|
-
* (empty
|
|
561
|
+
* (empty entries set) so callers know to stay silent.
|
|
562
|
+
*
|
|
563
|
+
* Protocol v2 gate (rc.18): payloads must carry `version: 2`. A null/missing
|
|
564
|
+
* payload returns [] silently; a payload with a mismatched `version` returns []
|
|
565
|
+
* after writing exactly one stderr breadcrumb so operators grepping a stuck-
|
|
566
|
+
* banner report can diagnose the version drift without source-diving.
|
|
345
567
|
*/
|
|
346
|
-
function renderSummary(payload) {
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
568
|
+
function renderSummary(payload, maxLen) {
|
|
569
|
+
if (!payload || payload.version !== 2) {
|
|
570
|
+
if (payload && payload.version !== undefined) {
|
|
571
|
+
try {
|
|
572
|
+
process.stderr.write(
|
|
573
|
+
`[fabric] hint payload version=${payload.version} unsupported (expected 2), skipping\n`,
|
|
574
|
+
);
|
|
575
|
+
} catch {}
|
|
576
|
+
}
|
|
577
|
+
return [];
|
|
578
|
+
}
|
|
579
|
+
// Protocol v2 (rc.18): the wire field is now `payload.entries`, matching what
|
|
580
|
+
// this renderer always called it locally. The historical `narrow` name (which
|
|
581
|
+
// degenerated in --all mode) has been retired without a compat shim per
|
|
582
|
+
// pre-user clean-slate policy.
|
|
583
|
+
const entries = Array.isArray(payload.entries) ? payload.entries : [];
|
|
584
|
+
if (entries.length === 0) return [];
|
|
585
|
+
|
|
586
|
+
const truncated = entries.length > TRUNCATION_THRESHOLD;
|
|
351
587
|
const banner = truncated
|
|
352
|
-
? `[fabric] Session start — ${
|
|
353
|
-
: `[fabric] Session start — ${
|
|
588
|
+
? `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available (truncated):`
|
|
589
|
+
: `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available:`;
|
|
354
590
|
|
|
355
|
-
const body = truncated ? renderTruncated(
|
|
591
|
+
const body = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
|
|
356
592
|
|
|
357
593
|
const lines = [banner, ...body];
|
|
358
594
|
const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
|
|
359
595
|
if (revHash !== null && revHash.length > 0) {
|
|
360
596
|
lines.push(` revision_hash: ${revHash}`);
|
|
361
597
|
}
|
|
598
|
+
|
|
599
|
+
// rc.22 Scope D T-D4 (TASK-011): meta auto-refresh breadcrumb. Emitted ONLY
|
|
600
|
+
// when the server's planContext() detected meta drift and rebuilt the meta
|
|
601
|
+
// in-place (auto_healed === true). One informational line — operators need
|
|
602
|
+
// a paper trail when revision_hash flips mid-session.
|
|
603
|
+
//
|
|
604
|
+
// Variant resolution:
|
|
605
|
+
// - Both hashes present → full transition line (`sha PREV → CUR`) with
|
|
606
|
+
// 8-char hex prefixes stripped of the `sha256:` scheme prefix.
|
|
607
|
+
// - auto_healed:true but previous_revision_hash missing → generic line
|
|
608
|
+
// (T10 noted the server may emit `auto_healed:true` alone if it lost
|
|
609
|
+
// the prior hash for any reason). Stays informational.
|
|
610
|
+
//
|
|
611
|
+
// i18n: routed through renderBanner so zh-CN / en / zh-CN-hybrid variants
|
|
612
|
+
// share one call site. fabric_language is resolved via readFabricLanguage()
|
|
613
|
+
// ONLY when the line is actually emitted (keeps the no-banner path free of
|
|
614
|
+
// the extra config read, matching the broadImportBanner site below).
|
|
615
|
+
if (payload.auto_healed === true) {
|
|
616
|
+
const variant = readFabricLanguage(process.cwd());
|
|
617
|
+
const prevRaw =
|
|
618
|
+
typeof payload.previous_revision_hash === "string"
|
|
619
|
+
? payload.previous_revision_hash
|
|
620
|
+
: null;
|
|
621
|
+
const curRaw =
|
|
622
|
+
typeof payload.revision_hash === "string" ? payload.revision_hash : null;
|
|
623
|
+
if (prevRaw && curRaw) {
|
|
624
|
+
// Strip optional `sha256:` scheme prefix, then take first 8 hex chars.
|
|
625
|
+
const stripScheme = (h) =>
|
|
626
|
+
h.startsWith("sha256:") ? h.slice("sha256:".length) : h;
|
|
627
|
+
const prev = stripScheme(prevRaw).slice(0, 8);
|
|
628
|
+
const cur = stripScheme(curRaw).slice(0, 8);
|
|
629
|
+
lines.push(
|
|
630
|
+
renderBanner("metaAutoRefreshedBanner", variant, { prev, cur }),
|
|
631
|
+
);
|
|
632
|
+
} else {
|
|
633
|
+
// Defensive: auto_healed:true but no usable previous hash → generic line.
|
|
634
|
+
lines.push(renderBanner("metaAutoRefreshedBannerGeneric", variant, {}));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
362
638
|
lines.push(" Use `fab_get_knowledge_sections` to fetch full content.");
|
|
363
639
|
return lines;
|
|
364
640
|
}
|
|
@@ -371,7 +647,33 @@ function renderSummary(payload) {
|
|
|
371
647
|
function main(env, stdio) {
|
|
372
648
|
try {
|
|
373
649
|
const cwd = (env && env.cwd) || process.cwd();
|
|
650
|
+
const now = (env && env.now) || new Date();
|
|
651
|
+
const nowMs = now instanceof Date ? now.getTime() : Number(now);
|
|
374
652
|
const err = (stdio && stdio.stderr) || process.stderr;
|
|
653
|
+
const out = (stdio && stdio.stdout) || process.stdout;
|
|
654
|
+
|
|
655
|
+
// v2.0.0-rc.33 W2-5 (P1-8): cooldown gate. When configured > 0 hours, the
|
|
656
|
+
// broad banner stays silent for that many hours after a successful emit.
|
|
657
|
+
// 0 (default) preserves rc.32 behavior — every SessionStart re-fires the
|
|
658
|
+
// banner. Test seam env.skipCooldown bypasses for unit tests.
|
|
659
|
+
const cooldownHours = readBroadCooldownHours(cwd);
|
|
660
|
+
if (cooldownHours > 0 && !(env && env.skipCooldown === true)) {
|
|
661
|
+
const lastEmitMs = readBroadLastEmit(cwd);
|
|
662
|
+
if (
|
|
663
|
+
typeof lastEmitMs === "number" &&
|
|
664
|
+
// rc.34 TASK-01 + review-fix (Gemini P1): when lastEmit is in the
|
|
665
|
+
// FUTURE relative to now (backward clock skew — NTP sync /
|
|
666
|
+
// suspend-wake / TZ change), the gate fires immediately. Otherwise
|
|
667
|
+
// standard cooldown check. Math.max(0, …) was a no-op (silent for
|
|
668
|
+
// cooldown + |skew| under both formulations); this guard actually
|
|
669
|
+
// heals the skew on the next invocation by treating future-stamped
|
|
670
|
+
// sidecar as "expired."
|
|
671
|
+
nowMs >= lastEmitMs &&
|
|
672
|
+
nowMs - lastEmitMs < cooldownHours * MS_PER_HOUR
|
|
673
|
+
) {
|
|
674
|
+
return; // still in cooldown — silent
|
|
675
|
+
}
|
|
676
|
+
}
|
|
375
677
|
|
|
376
678
|
// Test seam: env.payload short-circuits the CLI spawn so unit tests can
|
|
377
679
|
// feed canned plan-context-hint JSON without depending on a built CLI.
|
|
@@ -379,54 +681,113 @@ function main(env, stdio) {
|
|
|
379
681
|
env && env.payload !== undefined ? env.payload : invokePlanContextHint(cwd);
|
|
380
682
|
if (payload === null || payload === undefined) return; // silent
|
|
381
683
|
|
|
382
|
-
// rc.
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
// rc.
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
684
|
+
// v2.0.0-rc.33 W2-1 (P0-9): apply TopK slice BEFORE renderSummary so the
|
|
685
|
+
// grouped/truncation rendering operates on the bounded set. Slicing here
|
|
686
|
+
// (not inside renderSummary) keeps the formatter pure — it never has to
|
|
687
|
+
// know about the cap.
|
|
688
|
+
const topK = readBroadTopK(cwd);
|
|
689
|
+
const slicedPayload =
|
|
690
|
+
payload && Array.isArray(payload.entries) && payload.entries.length > topK
|
|
691
|
+
? { ...payload, entries: payload.entries.slice(0, topK) }
|
|
692
|
+
: payload;
|
|
693
|
+
|
|
694
|
+
// rc.35 TASK-06 (P0-10.b): summary-fallback substitution. Entries whose
|
|
695
|
+
// description.summary equals stable_id render as "<id> · <id>" and the
|
|
696
|
+
// AI skips fetching them; the fallback reads `## Summary` from the
|
|
697
|
+
// entry's .md file and swaps in the first paragraph. Best-effort —
|
|
698
|
+
// failure leaves the original opaque summary untouched.
|
|
699
|
+
let resolvedPayload = slicedPayload;
|
|
700
|
+
try {
|
|
701
|
+
if (slicedPayload && Array.isArray(slicedPayload.entries)) {
|
|
702
|
+
const resolvedEntries = resolveOpaqueSummaries(
|
|
703
|
+
slicedPayload.entries,
|
|
704
|
+
cwd,
|
|
705
|
+
typeof slicedPayload.revision_hash === "string" ? slicedPayload.revision_hash : "",
|
|
706
|
+
);
|
|
707
|
+
resolvedPayload = { ...slicedPayload, entries: resolvedEntries };
|
|
404
708
|
}
|
|
709
|
+
} catch {
|
|
710
|
+
// resolveOpaqueSummaries swallows its own errors; this catch is belt
|
|
711
|
+
// + suspenders for any unexpected exception from the lib layer.
|
|
405
712
|
}
|
|
406
713
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
714
|
+
// rc.8 underseed self-check: decide whether to surface the one-line
|
|
715
|
+
// `/fabric-import` recommendation banner alongside the broad summary.
|
|
716
|
+
const recommendImport = shouldRecommendImport(cwd);
|
|
717
|
+
|
|
718
|
+
// rc.12: broad-summary body is unconditionally rendered on every
|
|
719
|
+
// SessionStart fire (Skill-style progressive disclosure). The prior
|
|
720
|
+
// revision_hash cooldown gate (rc.7 T8 — rc.11) was removed because
|
|
721
|
+
// compact/clear-triggered SessionStart re-fires must re-inject the menu
|
|
722
|
+
// for the agent's working memory. rc.33 W2-5 reintroduces an opt-in
|
|
723
|
+
// hours-based cooldown via fabric-config (see gate above).
|
|
724
|
+
const summaryMaxLen = readSummaryMaxLen(cwd);
|
|
725
|
+
const lines = renderSummary(resolvedPayload, summaryMaxLen);
|
|
726
|
+
|
|
727
|
+
// v2.0.0-rc.37 NEW-23: resolve fabric_language ONCE per emit path —
|
|
728
|
+
// shared between the (existing) broadImportBanner branch and the new
|
|
729
|
+
// 'next step' nudge tail added below. 'match-existing' / unknown variants
|
|
730
|
+
// fold to 'en' inside renderBanner per UX i18n Policy class 1.
|
|
731
|
+
const fabricLanguageForEmit = lines.length > 0 || recommendImport ? readFabricLanguage(cwd) : null;
|
|
732
|
+
if (recommendImport && fabricLanguageForEmit !== null) {
|
|
733
|
+
lines.push(renderBanner("broadImportBanner", fabricLanguageForEmit, {}));
|
|
416
734
|
}
|
|
417
735
|
|
|
418
|
-
if (lines.length === 0) return; //
|
|
736
|
+
if (lines.length === 0) return; // nothing to say — silent exit
|
|
419
737
|
|
|
738
|
+
// v2.0.0-rc.37 NEW-23: SessionStart 索引末尾"下一步"引导。Tail line that
|
|
739
|
+
// tells the AI what to do with the broad index it just received. Without
|
|
740
|
+
// this, the model often parses the index and moves on without ever calling
|
|
741
|
+
// fab_recall / fab_plan_context. One-line nudge, bilingual.
|
|
742
|
+
const nextStepNudge =
|
|
743
|
+
fabricLanguageForEmit === "zh-CN"
|
|
744
|
+
? "下一步: 调 fab_recall(paths) 拿 KB 相关条目;或调 fab_plan_context 先看候选 description_index。"
|
|
745
|
+
: "Next: call fab_recall(paths) to fetch related KB entries, or fab_plan_context to preview the description_index first.";
|
|
746
|
+
lines.push(nextStepNudge);
|
|
747
|
+
|
|
748
|
+
// Stderr: always emit (human-facing breadcrumb + legacy contract).
|
|
420
749
|
for (const line of lines) {
|
|
421
750
|
err.write(`${line}\n`);
|
|
422
751
|
}
|
|
423
752
|
|
|
424
|
-
//
|
|
425
|
-
//
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
|
|
429
|
-
|
|
753
|
+
// v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
|
|
754
|
+
// hint_reminder_to_context is true (default), serialize the same banner
|
|
755
|
+
// body as Claude Code's SessionStart hookSpecificOutput shape so the model
|
|
756
|
+
// receives the reminder IN-CONTEXT (rc.32 baseline cite-coverage 3.1%
|
|
757
|
+
// root cause: reminders never entered model context). Stderr stays the
|
|
758
|
+
// host-facing channel.
|
|
759
|
+
//
|
|
760
|
+
// Failure to write JSON envelope must NOT crash the hook — stderr already
|
|
761
|
+
// delivered, the stdout layer is best-effort.
|
|
762
|
+
// v2.0.0-rc.33 W4 review-fix (gemini High-1): the stdout JSON envelope
|
|
763
|
+
// is Claude Code-specific (hookSpecificOutput.additionalContext contract).
|
|
764
|
+
// Codex CLI / Cursor don't parse it — leaking it to their stdout risks
|
|
765
|
+
// either polluting the terminal or crashing the host's hook-parsing
|
|
766
|
+
// pipeline. CLAUDE_PROJECT_DIR is set by CC when invoking hooks (see
|
|
767
|
+
// packages/cli/templates/hooks/configs/claude-code.json sigil paths);
|
|
768
|
+
// its presence is the single-bit "this is Claude Code" signal (now via
|
|
769
|
+
// the shared client-adapter, rc.37 NEW-30).
|
|
770
|
+
const reminderToContext = readReminderToContext(cwd) && isClaudeCode();
|
|
771
|
+
if (reminderToContext && !(env && env.skipStdout === true)) {
|
|
772
|
+
try {
|
|
773
|
+
const envelope = {
|
|
774
|
+
hookSpecificOutput: {
|
|
775
|
+
hookEventName: "SessionStart",
|
|
776
|
+
additionalContext: lines.join("\n"),
|
|
777
|
+
},
|
|
778
|
+
};
|
|
779
|
+
out.write(`${JSON.stringify(envelope)}\n`);
|
|
780
|
+
} catch {
|
|
781
|
+
// Best-effort — stderr is the durable contract
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// v2.0.0-rc.33 W2-5 (P1-8): record successful emit timestamp for the
|
|
786
|
+
// cooldown gate's next-invocation check. Skip when cooldown is disabled
|
|
787
|
+
// (cooldownHours === 0) to avoid polluting the FS with a never-read
|
|
788
|
+
// sidecar on rc.32-style "no cooldown" workspaces.
|
|
789
|
+
if (cooldownHours > 0 && !(env && env.skipCooldownWrite === true)) {
|
|
790
|
+
writeBroadLastEmit(cwd, nowMs);
|
|
430
791
|
}
|
|
431
792
|
} catch {
|
|
432
793
|
// Silent — never block session start on hook failure.
|
|
@@ -441,20 +802,33 @@ module.exports = {
|
|
|
441
802
|
renderTruncated,
|
|
442
803
|
renderSummary,
|
|
443
804
|
truncateSummary,
|
|
444
|
-
// rc.
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
805
|
+
// rc.8 underseed self-check helpers (exported for unit testing).
|
|
806
|
+
countCanonicalNodes,
|
|
807
|
+
readUnderseedThreshold,
|
|
808
|
+
isImportTouched,
|
|
809
|
+
shouldRecommendImport,
|
|
810
|
+
// v2.0.0-rc.33 W2-1 / W2-5 / W2-6 helpers.
|
|
811
|
+
readBroadTopK,
|
|
812
|
+
readBroadCooldownHours,
|
|
813
|
+
readReminderToContext,
|
|
814
|
+
readBroadLastEmit,
|
|
815
|
+
writeBroadLastEmit,
|
|
816
|
+
readSummaryMaxLen,
|
|
449
817
|
CONSTANTS: {
|
|
450
818
|
TRUNCATION_THRESHOLD,
|
|
451
819
|
CLI_TIMEOUT_MS,
|
|
452
|
-
SUMMARY_MAX_LEN,
|
|
820
|
+
SUMMARY_MAX_LEN: DEFAULT_SUMMARY_MAX_LEN,
|
|
821
|
+
DEFAULT_SUMMARY_MAX_LEN,
|
|
453
822
|
CANONICAL_TYPE_ORDER,
|
|
454
823
|
MATURITY_PROVEN,
|
|
455
824
|
MATURITY_VERIFIED,
|
|
456
825
|
MATURITY_DRAFT,
|
|
457
|
-
|
|
826
|
+
DEFAULT_UNDERSEED_NODE_THRESHOLD,
|
|
827
|
+
KNOWLEDGE_CANONICAL_TYPES,
|
|
828
|
+
DEFAULT_HINT_BROAD_TOP_K,
|
|
829
|
+
DEFAULT_HINT_BROAD_COOLDOWN_HOURS,
|
|
830
|
+
DEFAULT_HINT_REMINDER_TO_CONTEXT,
|
|
831
|
+
HINT_BROAD_LAST_EMIT_FILE_NAME,
|
|
458
832
|
},
|
|
459
833
|
};
|
|
460
834
|
|