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