@fenglimg/fabric-cli 2.1.0-rc.2 → 2.2.0-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/dist/chunk-27HK6H5Y.js +69 -0
- package/dist/{chunk-BATF4PEJ.js → chunk-2KBCTMID.js} +31 -8
- package/dist/chunk-3D7B2UAZ.js +149 -0
- package/dist/{chunk-MF3OTILQ.js → chunk-3IOLS5EK.js} +48 -42
- package/dist/{plan-context-hint-FC6P3WFE.js → chunk-722JU5BP.js} +52 -12
- package/dist/{chunk-F46ORPOA.js → chunk-7ZDXBOOU.js} +271 -166
- package/dist/{doctor-QVNPHLJK.js → chunk-E7HJUU34.js} +248 -72
- package/dist/chunk-EOT63RDH.js +36 -0
- package/dist/chunk-FNHDQTPC.js +16 -0
- package/dist/chunk-HORSMSZL.js +26 -0
- package/dist/chunk-NLNH64A3.js +43 -0
- package/dist/{chunk-WU6GAPKH.js → chunk-PTGQAZEW.js} +12 -4
- package/dist/chunk-QFIVFZRH.js +13 -0
- package/dist/chunk-QPAW6IYT.js +387 -0
- package/dist/{chunk-COI5VDFU.js → chunk-WA3DYGSY.js} +1 -2
- package/dist/{config-XJIPZNUP.js → config-A3LTECAY.js} +4 -3
- package/dist/context-UJCGYOT6.js +117 -0
- package/dist/doctor-MDTZWKBK.js +24 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +167 -16
- package/dist/info-7FKBTMVO.js +139 -0
- package/dist/install-v2-RINEA24K.js +3279 -0
- package/dist/{metrics-ACEQFPDU.js → metrics-HMFH4YHK.js} +22 -9
- package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-XSG77LL3.js} +48 -27
- package/dist/plan-context-hint-5TNGH3R4.js +12 -0
- package/dist/scope-explain-HLJZ2M33.js +48 -0
- package/dist/status-4R3TM4FJ.js +37 -0
- package/dist/store-HOCORVL3.js +563 -0
- package/dist/sync-DT5UJMMR.js +418 -0
- package/dist/{uninstall-TAXSUSKH.js → uninstall-IFN2KYBK.js} +128 -140
- package/dist/whoami-ITGEFWH4.js +49 -0
- package/package.json +7 -5
- package/templates/hooks/cite-policy-evict.cjs +412 -160
- package/templates/hooks/configs/README.md +14 -27
- package/templates/hooks/configs/claude-code.json +17 -2
- package/templates/hooks/configs/codex-hooks.json +15 -3
- package/templates/hooks/fabric-hint.cjs +573 -180
- package/templates/hooks/knowledge-hint-broad.cjs +648 -190
- package/templates/hooks/knowledge-hint-narrow.cjs +123 -77
- package/templates/hooks/lib/banner-i18n.cjs +31 -0
- package/templates/hooks/lib/bindings-snapshot-reader.cjs +118 -7
- package/templates/hooks/lib/cite-line-parser.cjs +12 -20
- package/templates/hooks/lib/client-adapter.cjs +66 -7
- package/templates/hooks/lib/injection-log.cjs +91 -0
- package/templates/hooks/lib/nudge-policy.cjs +117 -0
- package/templates/hooks/lib/state-store.cjs +90 -11
- package/templates/hooks/post-tooluse-mutation.cjs +386 -0
- package/templates/hooks/session-end-marker.cjs +140 -0
- package/templates/skills/fabric/SKILL.md +100 -0
- package/templates/skills/fabric-archive/SKILL.md +35 -24
- package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
- package/templates/skills/fabric-archive/ref/i18n-policy.md +2 -3
- package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +2 -3
- package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +1 -1
- package/templates/skills/fabric-archive/ref/phase-3-6-related-edges.md +18 -0
- package/templates/skills/fabric-archive/ref/phase-3-7-semantic-scope.md +47 -0
- package/templates/skills/fabric-audit/SKILL.md +63 -0
- package/templates/skills/fabric-connect/SKILL.md +48 -0
- package/templates/skills/fabric-import/SKILL.md +7 -7
- package/templates/skills/fabric-import/ref/i18n-policy.md +2 -3
- package/templates/skills/fabric-import/ref/state-recovery.md +1 -2
- package/templates/skills/fabric-review/SKILL.md +16 -5
- package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
- package/templates/skills/fabric-review/ref/i18n-policy.md +2 -3
- package/templates/skills/fabric-review/ref/output-contract.md +1 -1
- package/templates/skills/fabric-review/ref/per-mode-flows.md +2 -2
- package/templates/skills/fabric-review/ref/worked-examples.md +1 -1
- package/templates/skills/fabric-store/SKILL.md +44 -0
- package/templates/skills/fabric-sync/SKILL.md +1 -1
- package/templates/skills/lib/shared-policy.md +2 -2
- package/dist/chunk-HFQVXY6P.js +0 -86
- package/dist/chunk-L4Q55UC4.js +0 -52
- package/dist/chunk-LFIKMVY7.js +0 -27
- package/dist/chunk-PWLW3B57.js +0 -18
- package/dist/chunk-RYAFBNES.js +0 -33
- package/dist/chunk-T5RPGCCM.js +0 -40
- package/dist/chunk-WWNXR34K.js +0 -49
- package/dist/install-2HDO5FTQ.js +0 -2683
- package/dist/scope-explain-2F2R5URO.js +0 -33
- package/dist/status-GLQWLWH6.js +0 -23
- package/dist/store-XTSE5TY6.js +0 -105
- package/dist/sync-BJCWDPNC.js +0 -245
- package/dist/whoami-B6AEMSEV.js +0 -31
- package/templates/hooks/configs/cursor-hooks.json +0 -18
- package/templates/hooks/lib/cite-contract-reminder.cjs +0 -179
- package/templates/hooks/lib/summary-fallback.cjs +0 -210
|
@@ -12,28 +12,20 @@
|
|
|
12
12
|
* narrow-injection sibling (E2, knowledge-hint-narrow.cjs) handles
|
|
13
13
|
* per-Edit/Write hints with a session-hints cache.
|
|
14
14
|
*
|
|
15
|
-
* Output contract (
|
|
15
|
+
* Output contract (W2 / KT-DEC-0027/0028/0029 — the SessionStart spine):
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
* [fabric]
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* Use `fab_get_knowledge_sections` to fetch full content.
|
|
17
|
+
* AI sink (additionalContext) — the dynamically generated "MEMORY.md":
|
|
18
|
+
* [fabric:SessionStart] <store>
|
|
19
|
+
* ALWAYS-ACTIVE RULES (unconditional · act on the line): # guideline/model, BROAD only
|
|
20
|
+
* [guideline] team:KT-GLD-0001 · <summary> # INDEX line; body on demand (KT-DEC-0036)
|
|
21
|
+
* REFERENCE (situational · Read when must_read_if fires): # decision/pitfall/process, BROAD
|
|
22
|
+
* [decision] team:KT-DEC-0001 — <must_read_if>
|
|
23
|
+
* … N more folded (broad index > backstop 50; prune via fabric-audit)
|
|
24
|
+
* Load full content: fab_recall(paths), or Read <store>/knowledge/<type>/<id>--*.md
|
|
26
25
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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.
|
|
26
|
+
* Human sink (systemMessage) — broad-only census breadcrumb; SessionStart is
|
|
27
|
+
* SILENT about narrow-scoped knowledge (no on-demand counts, no
|
|
28
|
+
* dropped-other-project line).
|
|
37
29
|
*
|
|
38
30
|
* When 0 entries / CLI unavailable / CLI error / parse failure:
|
|
39
31
|
* (no output — silent exit 0)
|
|
@@ -50,12 +42,17 @@ const { spawnSync } = require("node:child_process");
|
|
|
50
42
|
const { existsSync, readdirSync, readFileSync } = require("node:fs");
|
|
51
43
|
const { join } = require("node:path");
|
|
52
44
|
|
|
45
|
+
// W1-01 (ISS-012): the SessionStart broad hook appends a hook_surface_emitted
|
|
46
|
+
// event to the shared events.jsonl. Under multi-window concurrency a bare
|
|
47
|
+
// appendFileSync can interleave a partial write; route through the advisory-lock
|
|
48
|
+
// primitive (drop-on-contention, best-effort — matches injection-log).
|
|
49
|
+
const { appendLockedLine } = require("./lib/injection-log.cjs");
|
|
50
|
+
|
|
53
51
|
// rc.16 TASK-003: shared banner-i18n lib (resolves fabric_language config and
|
|
54
52
|
// renders localized banner text). Mirror of the wiring in fabric-hint.cjs
|
|
55
53
|
// (TASK-002). Variant is resolved ONCE per main() invocation via
|
|
56
54
|
// readFabricLanguage(cwd) and threaded into renderBanner — no fs in render path.
|
|
57
55
|
const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
|
|
58
|
-
const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
|
|
59
56
|
// v2.0.0-rc.37 NEW-19: shared fabric-config reader + sidecar I/O. Replaces the
|
|
60
57
|
// five per-key readFileSync+parse config readers (one parse per fire now) and
|
|
61
58
|
// the bespoke last-emit sidecar helpers. The L78 "refactor into lib/ if a
|
|
@@ -63,11 +60,23 @@ const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
|
|
|
63
60
|
const {
|
|
64
61
|
readConfigNumber,
|
|
65
62
|
readConfigBoolean,
|
|
63
|
+
readConfigString,
|
|
66
64
|
} = require("./lib/config-cache.cjs");
|
|
67
65
|
const { readTextState, writeTextState } = require("./lib/state-store.cjs");
|
|
68
66
|
// v2.0.0-rc.37 NEW-30: shared client detection (replaces the inline
|
|
69
67
|
// CLAUDE_PROJECT_DIR single-bit check below).
|
|
70
|
-
|
|
68
|
+
// v2.2 dual-sink (Goal A): + emitDualSink (two-channel SessionStart emit).
|
|
69
|
+
const { isClaudeCode, detectClient, emitDualSink } = require("./lib/client-adapter.cjs");
|
|
70
|
+
// v2.2 dual-sink (Goal A / D4): human-output gate (nudge_mode + observe.*). Only
|
|
71
|
+
// governs the human systemMessage — the AI additionalContext is emitted
|
|
72
|
+
// regardless (flow ⊥ observation). Optional require so an old install lacking the
|
|
73
|
+
// lib degrades to "always emit human" (the pre-dual-sink default).
|
|
74
|
+
let nudgePolicy = null;
|
|
75
|
+
try {
|
|
76
|
+
nudgePolicy = require("./lib/nudge-policy.cjs");
|
|
77
|
+
} catch {
|
|
78
|
+
// Lib missing (old install) — human sink always emits (legacy behavior).
|
|
79
|
+
}
|
|
71
80
|
// v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
|
|
72
81
|
// resolved-bindings snapshot. The hook NEVER re-resolves stores or walks store
|
|
73
82
|
// trees — it only echoes the read-set the CLI already computed. Best-effort.
|
|
@@ -77,20 +86,72 @@ try {
|
|
|
77
86
|
} catch {
|
|
78
87
|
// Lib missing (old install) — store labels degrade to silent absence.
|
|
79
88
|
}
|
|
89
|
+
// v2.2 HK3-telemetry (W3-T1): injection-side per-inject logger. Optional require
|
|
90
|
+
// so an old install lacking the lib degrades to silent absence (no telemetry,
|
|
91
|
+
// hook still works).
|
|
92
|
+
let injectionLog = null;
|
|
93
|
+
try {
|
|
94
|
+
injectionLog = require("./lib/injection-log.cjs");
|
|
95
|
+
} catch {
|
|
96
|
+
// Lib missing (old install) — injection telemetry degrades to silent absence.
|
|
97
|
+
}
|
|
80
98
|
|
|
81
|
-
// Read the
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
function readProjectId(cwd) {
|
|
99
|
+
// Read the workspace binding id from `.fabric/fabric-config.json` (the snapshot
|
|
100
|
+
// key). Defaults to project_id when workspace_binding_id is absent.
|
|
101
|
+
function readWorkspaceBindingId(cwd) {
|
|
85
102
|
try {
|
|
86
103
|
const raw = readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8");
|
|
87
104
|
const parsed = JSON.parse(raw);
|
|
105
|
+
if (typeof parsed.workspace_binding_id === "string") return parsed.workspace_binding_id;
|
|
88
106
|
return typeof parsed.project_id === "string" ? parsed.project_id : null;
|
|
89
107
|
} catch {
|
|
90
108
|
return null;
|
|
91
109
|
}
|
|
92
110
|
}
|
|
93
111
|
|
|
112
|
+
function readSnapshotCanonicalCount(projectRoot) {
|
|
113
|
+
// No reader / not bound → degrade to an empty corpus (0), the documented
|
|
114
|
+
// store-only behavior (KT-DEC-0007). Only the "snapshot EXISTS but predates
|
|
115
|
+
// knowledge_store_dirs" case below is undeterminable → null (skip).
|
|
116
|
+
if (bindingsSnapshotReader === null) {
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
const bindingId = readWorkspaceBindingId(projectRoot);
|
|
120
|
+
if (bindingId === null) {
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
const snapshot = bindingsSnapshotReader.readBindingsSnapshot(bindingId);
|
|
125
|
+
// No snapshot file at all → treat as an empty corpus (KT-DEC-0007),
|
|
126
|
+
// preserving the fresh-project underseed nudge.
|
|
127
|
+
if (!snapshot) {
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
// LIVE recount off the snapshot's resolved store dirs. The cached
|
|
131
|
+
// knowledge_stats.canonical_count is frozen at snapshot-write time and goes
|
|
132
|
+
// stale when store content syncs in out-of-band (e.g. the store grew from 1
|
|
133
|
+
// → 57 nodes via a `git pull`/cross-workspace sync that never regenerated
|
|
134
|
+
// THIS workspace's snapshot), which mis-fired the "knowledge sparse"
|
|
135
|
+
// underseed nudge (KT-PIT-0017, same stale-projection root cause).
|
|
136
|
+
const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
|
|
137
|
+
// #3: a snapshot that predates knowledge_store_dirs makes liveKnowledgeStats
|
|
138
|
+
// return null — the count is undeterminable and the cached projection is
|
|
139
|
+
// unreliable. Return null (not 0) so countCanonicalNodes / shouldRecommendImport
|
|
140
|
+
// SKIP the nudge instead of false-firing on stale data; the snapshot
|
|
141
|
+
// self-heals on the next install/sync. A genuine live 0 (dirs present, no
|
|
142
|
+
// *.md) still returns 0 and fires correctly.
|
|
143
|
+
if (live === null) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
return Number.isFinite(live.canonicalCount) ? Math.floor(live.canonicalCount) : 0;
|
|
147
|
+
} catch {
|
|
148
|
+
// Read/parse fault → degrade to empty (0), preserving prior behavior. The
|
|
149
|
+
// only undeterminable→skip path is the explicit live===null above.
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
94
155
|
// -----------------------------------------------------------------------------
|
|
95
156
|
// rc.12: SessionStart broad-menu is now unconditionally emitted on every
|
|
96
157
|
// SessionStart fire (matching Skill-style progressive disclosure). Prior
|
|
@@ -108,7 +169,6 @@ const FABRIC_DIR_REL = ".fabric";
|
|
|
108
169
|
// cannot `require` each other. If a third hook ever needs the same logic,
|
|
109
170
|
// refactor into packages/cli/templates/hooks/lib/. Keep these values in sync
|
|
110
171
|
// with packages/cli/templates/hooks/fabric-hint.cjs.
|
|
111
|
-
const AGENTS_META_FILE = "agents.meta.json";
|
|
112
172
|
const IMPORT_STATE_FILE = ".import-state.json";
|
|
113
173
|
const KNOWLEDGE_CANONICAL_TYPES = [
|
|
114
174
|
"decisions",
|
|
@@ -119,11 +179,12 @@ const KNOWLEDGE_CANONICAL_TYPES = [
|
|
|
119
179
|
];
|
|
120
180
|
const DEFAULT_UNDERSEED_NODE_THRESHOLD = 10;
|
|
121
181
|
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
182
|
+
// W2-1 (KT-DEC-0028): the broad index is shown in FULL (no top-K hard cap). The
|
|
183
|
+
// only scale guard is a backstop: when the rendered broad index exceeds this
|
|
184
|
+
// many lines, the overflow tail folds into one marker that doubles as a drift
|
|
185
|
+
// signal (the W4 doctor `broad-index-drift` lint is the authoritative detector).
|
|
186
|
+
// Overridable via fabric-config.json#broad_index_backstop (W4 schema range 20..500).
|
|
187
|
+
const DEFAULT_HINT_BROAD_INDEX_BACKSTOP = 50;
|
|
127
188
|
|
|
128
189
|
// v2.0.0-rc.33 W2-5 (P1-8): cooldown (in hours) between broad-hint re-emits.
|
|
129
190
|
// Default 0 preserves rc.32 behavior — every SessionStart re-fires the banner.
|
|
@@ -153,37 +214,15 @@ const DEFAULT_HINT_REMINDER_TO_CONTEXT = true;
|
|
|
153
214
|
// -----------------------------------------------------------------------------
|
|
154
215
|
|
|
155
216
|
/**
|
|
156
|
-
* Count canonical knowledge entries
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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).
|
|
217
|
+
* Count canonical knowledge entries from the CLI-generated resolved-bindings
|
|
218
|
+
* snapshot. Store-only: hooks never walk project-local knowledge or store
|
|
219
|
+
* trees — a missing snapshot degrades to zero (KT-DEC-0007).
|
|
164
220
|
*/
|
|
165
221
|
function countCanonicalNodes(projectRoot) {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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;
|
|
222
|
+
// #3: null = undeterminable (old snapshot lacking store dirs, or no binding
|
|
223
|
+
// context). Propagate it — shouldRecommendImport SKIPS on null rather than
|
|
224
|
+
// treating it as zero and false-firing the underseed nudge on a stale corpus.
|
|
225
|
+
return readSnapshotCanonicalCount(projectRoot);
|
|
187
226
|
}
|
|
188
227
|
|
|
189
228
|
/**
|
|
@@ -200,14 +239,15 @@ function readUnderseedThreshold(projectRoot) {
|
|
|
200
239
|
}
|
|
201
240
|
|
|
202
241
|
/**
|
|
203
|
-
*
|
|
204
|
-
* the broad
|
|
205
|
-
* schema's
|
|
242
|
+
* W2-1 (KT-DEC-0028): resolve broad_index_backstop from fabric-config.json. Caps
|
|
243
|
+
* the rendered broad index line count; the overflow tail folds into a drift
|
|
244
|
+
* marker. Validates the W4 schema's 20..500 range inline so a malformed config
|
|
245
|
+
* silently falls back to the default.
|
|
206
246
|
*/
|
|
207
|
-
function
|
|
208
|
-
return readConfigNumber(projectRoot, "
|
|
209
|
-
min:
|
|
210
|
-
max:
|
|
247
|
+
function readBroadIndexBackstop(projectRoot) {
|
|
248
|
+
return readConfigNumber(projectRoot, "broad_index_backstop", DEFAULT_HINT_BROAD_INDEX_BACKSTOP, {
|
|
249
|
+
min: 20,
|
|
250
|
+
max: 500,
|
|
211
251
|
floor: true,
|
|
212
252
|
});
|
|
213
253
|
}
|
|
@@ -293,9 +333,11 @@ function isImportTouched(projectRoot) {
|
|
|
293
333
|
* surface the one-line `/fabric-import` recommendation banner.
|
|
294
334
|
*
|
|
295
335
|
* Three-condition truth table (ALL must hold to return true):
|
|
296
|
-
* 1.
|
|
297
|
-
* (
|
|
298
|
-
* is meaningless — `fabric-import` requires
|
|
336
|
+
* 1. the workspace is fabric-bound — readWorkspaceBindingId(cwd) !== null
|
|
337
|
+
* (a resolved binding id in fabric-config.json; otherwise the
|
|
338
|
+
* recommendation is meaningless — `fabric-import` requires a bound
|
|
339
|
+
* workspace. Store-only: replaces the legacy derived-index-file
|
|
340
|
+
* existence probe.)
|
|
299
341
|
* 2. countCanonicalNodes(cwd) < readUnderseedThreshold(cwd)
|
|
300
342
|
* (knowledge graph is sparse — import would meaningfully enrich it).
|
|
301
343
|
* 3. isImportTouched(cwd) === 'absent'
|
|
@@ -306,13 +348,26 @@ function isImportTouched(projectRoot) {
|
|
|
306
348
|
*
|
|
307
349
|
* Best-effort: any unexpected error → return false (do not nag on faults).
|
|
308
350
|
*/
|
|
309
|
-
function shouldRecommendImport(projectRoot) {
|
|
351
|
+
function shouldRecommendImport(projectRoot, liveTotal) {
|
|
310
352
|
try {
|
|
311
|
-
|
|
312
|
-
if (!existsSync(metaPath)) return false;
|
|
353
|
+
if (readWorkspaceBindingId(projectRoot) === null) return false;
|
|
313
354
|
|
|
314
355
|
const threshold = readUnderseedThreshold(projectRoot);
|
|
315
|
-
|
|
356
|
+
// P0 (Goal H3 / KT-PIT-0017 + KT-PIT-0019): prefer the LIVE census total the
|
|
357
|
+
// HUD displays as the single count source. The census walks the read-set
|
|
358
|
+
// fresh every fire (never a frozen snapshot projection), so feeding it here
|
|
359
|
+
// makes the import nudge and the HUD agree by construction — killing the
|
|
360
|
+
// "HUD shows 61 entries but the nudge claims the KB is sparse" contradiction
|
|
361
|
+
// the stale-snapshot count produced. Fall back to the snapshot-derived count
|
|
362
|
+
// only when no live total is supplied (e.g. direct unit-test calls).
|
|
363
|
+
const nodeCount =
|
|
364
|
+
typeof liveTotal === "number" && Number.isFinite(liveTotal)
|
|
365
|
+
? liveTotal
|
|
366
|
+
: countCanonicalNodes(projectRoot);
|
|
367
|
+
// #3: undeterminable count (old snapshot predating knowledge_store_dirs) →
|
|
368
|
+
// skip. `null < threshold` coerces to true in JS, so an explicit guard is
|
|
369
|
+
// required — otherwise the stale-snapshot case would still false-fire.
|
|
370
|
+
if (nodeCount === null) return false;
|
|
316
371
|
if (nodeCount >= threshold) return false;
|
|
317
372
|
|
|
318
373
|
if (isImportTouched(projectRoot) !== "absent") return false;
|
|
@@ -338,6 +393,12 @@ function shouldRecommendImport(projectRoot) {
|
|
|
338
393
|
// truncation summary lines) consume it as a single source of truth.
|
|
339
394
|
const TRUNCATION_THRESHOLD = 12;
|
|
340
395
|
|
|
396
|
+
// Goal H4 action ladder — review rung: surface a single `/fabric-review` line when
|
|
397
|
+
// the LIVE pending backlog exceeds this. Mirrors fabric-hint.cjs's
|
|
398
|
+
// DEFAULT_REVIEW_HINT_PENDING_COUNT (the Stop hook's review threshold) so the two
|
|
399
|
+
// surfaces agree on "how much pending is too much". Strictly `> threshold`.
|
|
400
|
+
const REVIEW_PENDING_THRESHOLD = 10;
|
|
401
|
+
|
|
341
402
|
// `fabric plan-context-hint` is a thin wrapper over planContext(); on a
|
|
342
403
|
// well-seeded repo it returns in ~100ms. Two-second cap is defensive — any
|
|
343
404
|
// pathological hang must not stall session start.
|
|
@@ -499,7 +560,18 @@ function truncateSummary(raw, maxLen) {
|
|
|
499
560
|
function formatEntryLine(entry, maxLen) {
|
|
500
561
|
const id = entry.id || "(no-id)";
|
|
501
562
|
const summary = truncateSummary(entry.summary, maxLen);
|
|
502
|
-
|
|
563
|
+
// lifecycle-refactor W3-T2 (§7 图谱消费 / §5 hook 沿 related 二阶召回): when this
|
|
564
|
+
// entry was pulled in by following a surfaced entry's `related` graph edge,
|
|
565
|
+
// tag the line with its provenance so the agent knows it arrived via the graph,
|
|
566
|
+
// not its own ranking. Omitted entirely for ordinarily-ranked entries — no fake
|
|
567
|
+
// "related" annotation is ever synthesized (graph-empty honesty).
|
|
568
|
+
const provenance =
|
|
569
|
+
typeof entry.related_to === "string" && entry.related_to.length > 0
|
|
570
|
+
? ` (related-to-${entry.related_to})`
|
|
571
|
+
: "";
|
|
572
|
+
return summary.length > 0
|
|
573
|
+
? ` - ${id} · ${summary}${provenance}`
|
|
574
|
+
: ` - ${id}${provenance}`;
|
|
503
575
|
}
|
|
504
576
|
|
|
505
577
|
/**
|
|
@@ -610,6 +682,8 @@ function renderSummary(payload, maxLen) {
|
|
|
610
682
|
? `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available (truncated):`
|
|
611
683
|
: `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available:`;
|
|
612
684
|
|
|
685
|
+
// KT-DEC-0028 completeness: the rendered census is bounded by the per-line
|
|
686
|
+
// maxLen + TRUNCATION_THRESHOLD grouped mode, not by a body char budget.
|
|
613
687
|
const body = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
|
|
614
688
|
|
|
615
689
|
const lines = [banner, ...body];
|
|
@@ -657,15 +731,423 @@ function renderSummary(payload, maxLen) {
|
|
|
657
731
|
}
|
|
658
732
|
}
|
|
659
733
|
|
|
660
|
-
|
|
734
|
+
// W2-4 (KT-DEC-0026): single lean retrieval flow. The two-step
|
|
735
|
+
// fab_plan_context → fab_get_knowledge_sections footer is retired — fab_recall
|
|
736
|
+
// returns descriptions + read paths, and bodies load via a native Read.
|
|
737
|
+
lines.push(
|
|
738
|
+
" Load full content: `fab_recall(paths)`, or Read `<store>/knowledge/<type>/<id>--*.md` directly.",
|
|
739
|
+
);
|
|
661
740
|
return lines;
|
|
662
741
|
}
|
|
663
742
|
|
|
743
|
+
// -----------------------------------------------------------------------------
|
|
744
|
+
// v2.2 dual-sink (Goal A): two-sink SessionStart rendering.
|
|
745
|
+
//
|
|
746
|
+
// HUMAN sink (systemMessage): a grouped census (§3 / D8) — always-loaded vs
|
|
747
|
+
// on-demand split + [team]/[personal] + ✗ dropped-other-project. Count-summary
|
|
748
|
+
// form (not a per-entry wall of text); the verbose nudge_mode appends the legacy
|
|
749
|
+
// per-entry renderSummary listing on top.
|
|
750
|
+
//
|
|
751
|
+
// AI sink (additionalContext): the always-active (guideline/model) BODIES (§3 /
|
|
752
|
+
// D9), bounded by the injection char budget with overflow degrading to summary +
|
|
753
|
+
// a recall pointer, followed by on-demand category counts. Replaces the legacy
|
|
754
|
+
// top_k=8 id-list that used to be the AI payload.
|
|
755
|
+
// -----------------------------------------------------------------------------
|
|
756
|
+
|
|
757
|
+
// Singular display label for a plural knowledge_type.
|
|
758
|
+
const TYPE_SINGULAR = {
|
|
759
|
+
decisions: "decision",
|
|
760
|
+
pitfalls: "pitfall",
|
|
761
|
+
guidelines: "guideline",
|
|
762
|
+
models: "model",
|
|
763
|
+
processes: "process",
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
const ALWAYS_TYPES = ["guidelines", "models"];
|
|
767
|
+
|
|
768
|
+
// Normalize a knowledge_type to its canonical PLURAL form. Frontmatter / entries
|
|
769
|
+
// may carry the singular ("decision") while the census keys on the plural enum
|
|
770
|
+
// ("decisions"); fold both so counting + display stay consistent.
|
|
771
|
+
const TYPE_TO_PLURAL = {
|
|
772
|
+
decision: "decisions",
|
|
773
|
+
pitfall: "pitfalls",
|
|
774
|
+
guideline: "guidelines",
|
|
775
|
+
model: "models",
|
|
776
|
+
process: "processes",
|
|
777
|
+
};
|
|
778
|
+
function toPluralType(type) {
|
|
779
|
+
return TYPE_TO_PLURAL[type] || type;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Fallback census when payload.census is absent (old CLI / unit-test payloads):
|
|
783
|
+
// count the (possibly sliced) entries by knowledge_type so the human banner still
|
|
784
|
+
// has something to group. Production payloads always carry the unsliced census.
|
|
785
|
+
function deriveCensusFromEntries(entries) {
|
|
786
|
+
const census = {
|
|
787
|
+
by_type: {},
|
|
788
|
+
by_layer: { team: 0, personal: 0, project: 0 },
|
|
789
|
+
broad_by_type: {},
|
|
790
|
+
narrow_total: 0,
|
|
791
|
+
dropped_other_project: 0,
|
|
792
|
+
total: 0,
|
|
793
|
+
};
|
|
794
|
+
if (!Array.isArray(entries)) return census;
|
|
795
|
+
for (const e of entries) {
|
|
796
|
+
const type = e && typeof e.type === "string" ? toPluralType(e.type) : null;
|
|
797
|
+
if (type === null) continue;
|
|
798
|
+
const isNarrow = e.relevance_scope === "narrow";
|
|
799
|
+
census.by_type[type] = (census.by_type[type] || 0) + 1;
|
|
800
|
+
if (isNarrow) census.narrow_total += 1;
|
|
801
|
+
else census.broad_by_type[type] = (census.broad_by_type[type] || 0) + 1;
|
|
802
|
+
census.total += 1;
|
|
803
|
+
}
|
|
804
|
+
return census;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Render the human-facing scope-primary status HUD (Goal H2). `lang` is
|
|
808
|
+
// "zh-CN" | other (en). Returns an array of lines (empty when census is empty).
|
|
809
|
+
//
|
|
810
|
+
// Shape (KT-DEC-0029 — SessionStart is scope-primary; broad is the spine that's
|
|
811
|
+
// injected this session, narrow surfaces contextually via the PreToolUse hint):
|
|
812
|
+
// ▸ [fabric] 共 N 条 · 团队X · 项目Y · 个人Z
|
|
813
|
+
// broad B · 本会话注入
|
|
814
|
+
// ├ 常驻规则 G+M guideline G · model M (KT-DEC-0027 resident tier)
|
|
815
|
+
// └ 情境参考 D+P+Pr decision D · pitfall P · process Pr (reference tier)
|
|
816
|
+
// narrow M · 编辑对应文件时浮现 (合计 only, no per-type)
|
|
817
|
+
// Self-consistency invariant: broad (= 常驻 + 参考) + narrow == total.
|
|
818
|
+
function renderHumanCensus(census, opts) {
|
|
819
|
+
const { lang } = opts || {};
|
|
820
|
+
const c = census || {};
|
|
821
|
+
const total = typeof c.total === "number" ? c.total : 0;
|
|
822
|
+
if (total === 0 && (c.dropped_other_project || 0) === 0) return [];
|
|
823
|
+
const zh = lang === "zh-CN";
|
|
824
|
+
|
|
825
|
+
const broadByType = c.broad_by_type || {};
|
|
826
|
+
const narrowTotal = typeof c.narrow_total === "number" ? c.narrow_total : 0;
|
|
827
|
+
// Per-tier broad counts. `broad_by_type` keys on the plural enum.
|
|
828
|
+
const g = broadByType.guidelines || 0;
|
|
829
|
+
const m = broadByType.models || 0;
|
|
830
|
+
const d = broadByType.decisions || 0;
|
|
831
|
+
const p = broadByType.pitfalls || 0;
|
|
832
|
+
const pr = broadByType.processes || 0;
|
|
833
|
+
const residentN = g + m; // 常驻规则 (always-active: guideline + model)
|
|
834
|
+
const referenceN = d + p + pr; // 情境参考 (decision + pitfall + process)
|
|
835
|
+
const broadN = residentN + referenceN;
|
|
836
|
+
|
|
837
|
+
const layer = c.by_layer || {};
|
|
838
|
+
const teamCount = layer.team || 0;
|
|
839
|
+
const personalCount = layer.personal || 0;
|
|
840
|
+
const projectCount = layer.project || 0;
|
|
841
|
+
|
|
842
|
+
const lines = [];
|
|
843
|
+
// Header: total entry count + semantic_scope breakdown (KT-MOD-0001 三轴).
|
|
844
|
+
const scopeSegs = [zh ? `团队 ${teamCount}` : `team ${teamCount}`];
|
|
845
|
+
if (projectCount > 0) scopeSegs.push(zh ? `项目 ${projectCount}` : `project ${projectCount}`);
|
|
846
|
+
scopeSegs.push(zh ? `个人 ${personalCount}` : `personal ${personalCount}`);
|
|
847
|
+
const totalLabel = zh ? `共 ${total} 条` : `${total} ${total === 1 ? "entry" : "entries"}`;
|
|
848
|
+
lines.push(`▸ [fabric] ${totalLabel} · ${scopeSegs.join(" · ")}`);
|
|
849
|
+
|
|
850
|
+
// broad spine — injected this session.
|
|
851
|
+
lines.push(zh ? ` broad ${broadN} · 本会话注入` : ` broad ${broadN} · injected this session`);
|
|
852
|
+
const residentDetail = [];
|
|
853
|
+
if (g > 0) residentDetail.push(`guideline ${g}`);
|
|
854
|
+
if (m > 0) residentDetail.push(`model ${m}`);
|
|
855
|
+
const refDetail = [];
|
|
856
|
+
if (d > 0) refDetail.push(`decision ${d}`);
|
|
857
|
+
if (p > 0) refDetail.push(`pitfall ${p}`);
|
|
858
|
+
if (pr > 0) refDetail.push(`process ${pr}`);
|
|
859
|
+
const dash = zh ? "—" : "—";
|
|
860
|
+
lines.push(
|
|
861
|
+
zh
|
|
862
|
+
? ` ├ 常驻规则 ${residentN} ${residentDetail.join(" · ") || dash}`
|
|
863
|
+
: ` ├ resident ${residentN} ${residentDetail.join(" · ") || dash}`,
|
|
864
|
+
);
|
|
865
|
+
lines.push(
|
|
866
|
+
zh
|
|
867
|
+
? ` └ 情境参考 ${referenceN} ${refDetail.join(" · ") || dash}`
|
|
868
|
+
: ` └ reference ${referenceN} ${refDetail.join(" · ") || dash}`,
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
// narrow remainder — 合计 only (no per-type; it's file-specific, surfaces on edit).
|
|
872
|
+
lines.push(
|
|
873
|
+
zh
|
|
874
|
+
? ` narrow ${narrowTotal} · 编辑对应文件时浮现`
|
|
875
|
+
: ` narrow ${narrowTotal} · surfaces when you edit matching files`,
|
|
876
|
+
);
|
|
877
|
+
return lines;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Goal H2: the SessionStart store label, scope-primary wording — `写入 X · 只读 Y`
|
|
881
|
+
// (write target receives new knowledge; the rest are read-only sources). Replaces
|
|
882
|
+
// the legacy `read-set stores: a (write), b (ro)` jargon line inline (kept local
|
|
883
|
+
// to this hook so the shared lib formatStoreLabels — used by other hooks — is
|
|
884
|
+
// untouched). Empty string when there is nothing to show.
|
|
885
|
+
function renderScopeStoreLabel(snapshot, lang) {
|
|
886
|
+
if (!snapshot || !snapshot.read_set || !Array.isArray(snapshot.read_set.stores)) return "";
|
|
887
|
+
const stores = snapshot.read_set.stores;
|
|
888
|
+
if (stores.length === 0) return "";
|
|
889
|
+
const zh = lang === "zh-CN";
|
|
890
|
+
const writeAlias = snapshot.write_target && snapshot.write_target.alias;
|
|
891
|
+
const writeStores = [];
|
|
892
|
+
const readonlyStores = [];
|
|
893
|
+
for (const s of stores) {
|
|
894
|
+
const alias = s && typeof s.alias === "string" ? s.alias : null;
|
|
895
|
+
if (alias === null) continue;
|
|
896
|
+
if (alias === writeAlias) writeStores.push(alias);
|
|
897
|
+
else readonlyStores.push(alias);
|
|
898
|
+
}
|
|
899
|
+
const segs = [];
|
|
900
|
+
if (writeStores.length > 0) segs.push((zh ? "写入 " : "write ") + writeStores.join(", "));
|
|
901
|
+
if (readonlyStores.length > 0) segs.push((zh ? "只读 " : "readonly ") + readonlyStores.join(", "));
|
|
902
|
+
if (segs.length === 0) return "";
|
|
903
|
+
return " " + segs.join(" · ");
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// W2 (KT-DEC-0027/0028/0029): render the AI-facing sink — the dynamically
|
|
907
|
+
// generated "MEMORY.md" spine injected into the SessionStart context. Two
|
|
908
|
+
// type-tiered sections over the BROAD knowledge (narrow stays silent — D0029):
|
|
909
|
+
//
|
|
910
|
+
// ALWAYS-ACTIVE RULES (guideline/model): INDEX LINE only (title + summary) —
|
|
911
|
+
// never the eager body (KT-DEC-0036). The body is one cheap on-demand fetch
|
|
912
|
+
// away, so injecting it on every SessionStart is a permanent context tax
|
|
913
|
+
// (KT-GLD-0005) we no longer pay; each entry stays individually visible.
|
|
914
|
+
// REFERENCE (decision/pitfall/process): TITLE + must_read_if hook only
|
|
915
|
+
// (situational; the agent Reads the body on demand) — never the body.
|
|
916
|
+
//
|
|
917
|
+
// `broadIndexBackstop` (D0028) caps the total rendered index lines; the overflow
|
|
918
|
+
// tail folds into one marker that doubles as the drift signal (fabric-audit /
|
|
919
|
+
// the W4 doctor lint is the authoritative detector). `entries` is the broad
|
|
920
|
+
// plan-context-hint entry list ({id,type,maturity,summary,relevance_scope,
|
|
921
|
+
// must_read_if}); `alwaysBodies` is always_bodies[] ({id,type,layer,summary,body}).
|
|
922
|
+
const REFERENCE_TYPES = new Set(["decision", "pitfall", "process"]);
|
|
923
|
+
|
|
924
|
+
function renderAiSink(opts) {
|
|
925
|
+
const { entries, alwaysBodies, storeLabel, broadIndexBackstop, summaryMaxLen, lang } =
|
|
926
|
+
opts || {};
|
|
927
|
+
const zh = lang === "zh-CN";
|
|
928
|
+
const bodies = Array.isArray(alwaysBodies) ? alwaysBodies : [];
|
|
929
|
+
// REFERENCE = broad decision/pitfall/process. narrow entries stay silent (D0029).
|
|
930
|
+
const referenceEntries = (Array.isArray(entries) ? entries : []).filter((e) => {
|
|
931
|
+
if (!e || e.relevance_scope === "narrow") return false;
|
|
932
|
+
return REFERENCE_TYPES.has(TYPE_SINGULAR[toPluralType(e.type)] || e.type);
|
|
933
|
+
});
|
|
934
|
+
// Nothing to inject → empty so main() stays silent on an empty knowledge base.
|
|
935
|
+
if (bodies.length === 0 && referenceEntries.length === 0) return "";
|
|
936
|
+
|
|
937
|
+
const backstop =
|
|
938
|
+
typeof broadIndexBackstop === "number" && broadIndexBackstop > 0 ? broadIndexBackstop : 0;
|
|
939
|
+
let indexCount = 0; // total rendered index lines (always + reference), for the backstop.
|
|
940
|
+
|
|
941
|
+
const lines = [];
|
|
942
|
+
lines.push(`[fabric:SessionStart] ${storeLabel || "store"}`);
|
|
943
|
+
|
|
944
|
+
// ALWAYS-ACTIVE RULES — index-only (title + summary), never the eager body.
|
|
945
|
+
lines.push(
|
|
946
|
+
zh
|
|
947
|
+
? "ALWAYS-ACTIVE RULES (无条件适用 · 照此行遵循,正文按需取):"
|
|
948
|
+
: "ALWAYS-ACTIVE RULES (unconditional · act on the line; body on demand):",
|
|
949
|
+
);
|
|
950
|
+
if (bodies.length === 0) {
|
|
951
|
+
lines.push(zh ? " (无 always-active 条目)" : " (none)");
|
|
952
|
+
} else {
|
|
953
|
+
// KT-DEC-0036: render each always-active entry as a single index line
|
|
954
|
+
// (title + summary). The body is one cheap on-demand fetch away (see footer),
|
|
955
|
+
// so injecting it on every SessionStart is a permanent context tax
|
|
956
|
+
// (KT-GLD-0005) we no longer pay.
|
|
957
|
+
for (const b of bodies) {
|
|
958
|
+
const label = `[${TYPE_SINGULAR[b.type] || b.type}] ${b.id}`;
|
|
959
|
+
const summary = typeof b.summary === "string" ? b.summary.trim() : "";
|
|
960
|
+
lines.push(summary.length > 0 ? ` ${label} · ${summary}` : ` ${label}`);
|
|
961
|
+
indexCount += 1;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
// REFERENCE — broad decision/pitfall/process: title + must_read_if hook.
|
|
966
|
+
if (referenceEntries.length > 0) {
|
|
967
|
+
lines.push(
|
|
968
|
+
zh
|
|
969
|
+
? "REFERENCE (情境触发 · 命中 must_read_if 时 Read / fab_recall):"
|
|
970
|
+
: "REFERENCE (situational · Read when must_read_if fires / fab_recall):",
|
|
971
|
+
);
|
|
972
|
+
let folded = 0;
|
|
973
|
+
for (const e of referenceEntries) {
|
|
974
|
+
if (backstop > 0 && indexCount >= backstop) {
|
|
975
|
+
folded += 1;
|
|
976
|
+
continue;
|
|
977
|
+
}
|
|
978
|
+
const type = TYPE_SINGULAR[toPluralType(e.type)] || e.type;
|
|
979
|
+
const rawHook =
|
|
980
|
+
typeof e.must_read_if === "string" && e.must_read_if.length > 0
|
|
981
|
+
? e.must_read_if
|
|
982
|
+
: typeof e.summary === "string"
|
|
983
|
+
? e.summary
|
|
984
|
+
: "";
|
|
985
|
+
const hookText = truncateSummary(rawHook, summaryMaxLen);
|
|
986
|
+
lines.push(hookText.length > 0 ? ` [${type}] ${e.id} — ${hookText}` : ` [${type}] ${e.id}`);
|
|
987
|
+
indexCount += 1;
|
|
988
|
+
}
|
|
989
|
+
// D0028 backstop: fold the overflow tail into one marker + drift signal.
|
|
990
|
+
if (folded > 0) {
|
|
991
|
+
lines.push(
|
|
992
|
+
zh
|
|
993
|
+
? ` … 另 ${folded} 条 broad 条目折叠 (broad index > backstop ${backstop})。先跑 fabric-audit 瘦身;确需全展示再调 .fabric/fabric-config.json#broad_index_backstop (20..500)`
|
|
994
|
+
: ` … ${folded} more broad entr${folded === 1 ? "y" : "ies"} folded (broad index > backstop ${backstop}). Run fabric-audit to prune first; raise .fabric/fabric-config.json#broad_index_backstop (20..500) only if you truly need them all`,
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// W2-4 footer: single lean retrieval flow — no two-step.
|
|
1000
|
+
lines.push(
|
|
1001
|
+
zh
|
|
1002
|
+
? "取正文: fab_recall(paths), 或 Read <store>/knowledge/<type>/<id>--*.md"
|
|
1003
|
+
: "Load full content: fab_recall(paths), or Read <store>/knowledge/<type>/<id>--*.md",
|
|
1004
|
+
);
|
|
1005
|
+
// H6 scope discipline: this sink carries ONLY broad (always-relevant) knowledge;
|
|
1006
|
+
// narrow (file-specific) entries surface contextually via the PreToolUse hint
|
|
1007
|
+
// when you edit a matching file (KT-DEC-0029). Stops the agent from assuming
|
|
1008
|
+
// SessionStart is the whole KB.
|
|
1009
|
+
lines.push(
|
|
1010
|
+
zh
|
|
1011
|
+
? "范围: 此处仅 broad(始终相关);narrow(文件专属)在你编辑对应文件时由 PreToolUse 浮现"
|
|
1012
|
+
: "Scope: broad only (always relevant) here; narrow (file-specific) surfaces via the PreToolUse hint when you edit a matching file",
|
|
1013
|
+
);
|
|
1014
|
+
return lines.join("\n");
|
|
1015
|
+
}
|
|
1016
|
+
|
|
664
1017
|
// -----------------------------------------------------------------------------
|
|
665
1018
|
// Main entry — invoked both as a CLI (require.main === module) and in-process
|
|
666
1019
|
// by tests. Wraps the entire flow in try/catch: ANY error → silent exit 0.
|
|
667
1020
|
// -----------------------------------------------------------------------------
|
|
668
1021
|
|
|
1022
|
+
// Block 5 (Option X): build the two SessionStart sinks (human systemMessage +
|
|
1023
|
+
// AI additionalContext) from a plan-context-hint payload, WITHOUT emitting or
|
|
1024
|
+
// recording telemetry. This is the single shared renderer: main() calls it then
|
|
1025
|
+
// emits + logs; `fabric context` calls it then prints (byte-identical injection
|
|
1026
|
+
// by construction — same code, same config/FS reads). Pure-ish: it reads config
|
|
1027
|
+
// + snapshot + .md summaries for `cwd` but has no stdout/ledger side effects.
|
|
1028
|
+
//
|
|
1029
|
+
// Returns:
|
|
1030
|
+
// human — gated final human text (null when gated off / empty)
|
|
1031
|
+
// ai — gated final AI text (null when reminder-to-context off / empty)
|
|
1032
|
+
// resolvedPayload — the plan-context payload, passed through unchanged (for telemetry / --explain)
|
|
1033
|
+
// hasRenderedContent — true when ANY sink rendered content (main's silent-exit gate)
|
|
1034
|
+
// reminderToContext — readReminderToContext(cwd) (telemetry target-channel)
|
|
1035
|
+
function buildSessionStartSinks(cwd, payload, env) {
|
|
1036
|
+
// KT-GLD-0006: the rc.35 opaque-summary runtime substitution
|
|
1037
|
+
// (resolveOpaqueSummaries) is retired. The write-time mechanical floor in
|
|
1038
|
+
// extractKnowledge (summary !== stable_id/slug + length floor) prevents
|
|
1039
|
+
// degenerate summaries at the source, so SessionStart no longer band-aids them
|
|
1040
|
+
// at render time; surviving legacy opaque summaries are fixed by the
|
|
1041
|
+
// review-time cold-eval audit pass.
|
|
1042
|
+
const resolvedPayload = payload;
|
|
1043
|
+
|
|
1044
|
+
const summaryMaxLen = readSummaryMaxLen(cwd);
|
|
1045
|
+
const fabricLanguageForEmit = readFabricLanguage(cwd);
|
|
1046
|
+
|
|
1047
|
+
const census =
|
|
1048
|
+
env && env.census !== undefined
|
|
1049
|
+
? env.census
|
|
1050
|
+
: payload && payload.census
|
|
1051
|
+
? payload.census
|
|
1052
|
+
: deriveCensusFromEntries(resolvedPayload && resolvedPayload.entries);
|
|
1053
|
+
const alwaysBodies =
|
|
1054
|
+
env && env.alwaysBodies !== undefined
|
|
1055
|
+
? env.alwaysBodies
|
|
1056
|
+
: payload && Array.isArray(payload.always_bodies)
|
|
1057
|
+
? payload.always_bodies
|
|
1058
|
+
: [];
|
|
1059
|
+
|
|
1060
|
+
// H3: the LIVE census total is the single count source for the import gate —
|
|
1061
|
+
// computed AFTER census so the nudge and the HUD agree by construction.
|
|
1062
|
+
const censusTotal = census && typeof census.total === "number" ? census.total : undefined;
|
|
1063
|
+
const recommendImport = shouldRecommendImport(cwd, censusTotal);
|
|
1064
|
+
|
|
1065
|
+
// Read the resolved-bindings snapshot ONCE — reused for the scope store label
|
|
1066
|
+
// (写入/只读) and the H4 review-rung pending count. Best-effort/decorative: any
|
|
1067
|
+
// failure leaves snapshot null and the dependent lines simply don't render.
|
|
1068
|
+
let snapshot = null;
|
|
1069
|
+
if (bindingsSnapshotReader !== null) {
|
|
1070
|
+
try {
|
|
1071
|
+
const bindingId = readWorkspaceBindingId(cwd);
|
|
1072
|
+
if (bindingId) snapshot = bindingsSnapshotReader.readBindingsSnapshot(bindingId);
|
|
1073
|
+
} catch {
|
|
1074
|
+
snapshot = null;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const humanGate =
|
|
1079
|
+
nudgePolicy !== null
|
|
1080
|
+
? nudgePolicy.resolveHumanSink(cwd, "session_start", {})
|
|
1081
|
+
: { emitHuman: true, verbosity: "normal" };
|
|
1082
|
+
|
|
1083
|
+
// ---- HUMAN sink: §3 grouped census (+ verbose per-entry detail). ----
|
|
1084
|
+
const humanLines = renderHumanCensus(census, { lang: fabricLanguageForEmit });
|
|
1085
|
+
if (humanLines.length > 0 && humanGate.verbosity === "verbose") {
|
|
1086
|
+
const detail = renderSummary(resolvedPayload, summaryMaxLen);
|
|
1087
|
+
humanLines.push(...detail);
|
|
1088
|
+
}
|
|
1089
|
+
// H2: scope store label — `写入 X · 只读 Y` (replaces the legacy read-set jargon).
|
|
1090
|
+
if (humanLines.length > 0 && snapshot !== null) {
|
|
1091
|
+
const storeLabel = renderScopeStoreLabel(snapshot, fabricLanguageForEmit);
|
|
1092
|
+
if (storeLabel) humanLines.push(storeLabel);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// H4 action ladder (KT-DEC-0007: nudge, never a gate). AT MOST ONE line, the
|
|
1096
|
+
// highest-priority rung wins, and steady state is fully silent:
|
|
1097
|
+
// 1. import — KB is sparse (recommendImport, off the live census total)
|
|
1098
|
+
// 2. review — pending backlog exceeds REVIEW_PENDING_THRESHOLD (live count)
|
|
1099
|
+
// 3. (silent)
|
|
1100
|
+
if (humanLines.length > 0 && fabricLanguageForEmit !== null) {
|
|
1101
|
+
if (recommendImport) {
|
|
1102
|
+
humanLines.push(renderBanner("broadImportBanner", fabricLanguageForEmit, {}));
|
|
1103
|
+
} else if (snapshot !== null && bindingsSnapshotReader !== null) {
|
|
1104
|
+
let pendingCount = 0;
|
|
1105
|
+
try {
|
|
1106
|
+
const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
|
|
1107
|
+
if (live && Number.isFinite(live.pendingCount)) pendingCount = Math.floor(live.pendingCount);
|
|
1108
|
+
} catch {
|
|
1109
|
+
pendingCount = 0;
|
|
1110
|
+
}
|
|
1111
|
+
if (pendingCount > REVIEW_PENDING_THRESHOLD) {
|
|
1112
|
+
humanLines.push(
|
|
1113
|
+
fabricLanguageForEmit === "zh-CN"
|
|
1114
|
+
? ` 📋 Fabric: ${pendingCount} 条 pending 待审,是否调 /fabric-review?`
|
|
1115
|
+
: ` 📋 Fabric: ${pendingCount} pending entries — run /fabric-review?`,
|
|
1116
|
+
);
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
// H5: the `下一步: …fab_recall…` AI-plumbing line is retired from the human sink
|
|
1122
|
+
// (the AI gets it from its own footer + the MCP server directive). Keep only the
|
|
1123
|
+
// pointer to the byte-identical inspector for this injection.
|
|
1124
|
+
if (humanLines.length > 0) {
|
|
1125
|
+
humanLines.push(
|
|
1126
|
+
fabricLanguageForEmit === "zh-CN"
|
|
1127
|
+
? " 看具体注入: fabric context (--explain 看每条来源)"
|
|
1128
|
+
: " Inspect this injection: fabric context (--explain for per-entry provenance)",
|
|
1129
|
+
);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// ---- AI sink: spine — always-active INDEX lines (no eager body, KT-DEC-0036)
|
|
1133
|
+
// + reference, bounded by the broad_index_backstop fold. ----
|
|
1134
|
+
const broadIndexBackstop = readBroadIndexBackstop(cwd);
|
|
1135
|
+
const aiText = renderAiSink({
|
|
1136
|
+
entries: resolvedPayload && Array.isArray(resolvedPayload.entries) ? resolvedPayload.entries : [],
|
|
1137
|
+
alwaysBodies,
|
|
1138
|
+
broadIndexBackstop,
|
|
1139
|
+
summaryMaxLen,
|
|
1140
|
+
lang: fabricLanguageForEmit,
|
|
1141
|
+
});
|
|
1142
|
+
|
|
1143
|
+
const hasRenderedContent = humanLines.length > 0 || (typeof aiText === "string" && aiText.length > 0);
|
|
1144
|
+
const human = humanGate.emitHuman && humanLines.length > 0 ? humanLines.join("\n") : null;
|
|
1145
|
+
const reminderToContext = readReminderToContext(cwd);
|
|
1146
|
+
const ai = reminderToContext && aiText && aiText.length > 0 ? aiText : null;
|
|
1147
|
+
|
|
1148
|
+
return { human, ai, resolvedPayload, hasRenderedContent, reminderToContext };
|
|
1149
|
+
}
|
|
1150
|
+
|
|
669
1151
|
function main(env, stdio) {
|
|
670
1152
|
try {
|
|
671
1153
|
const cwd = (env && env.cwd) || process.cwd();
|
|
@@ -703,123 +1185,98 @@ function main(env, stdio) {
|
|
|
703
1185
|
env && env.payload !== undefined ? env.payload : invokePlanContextHint(cwd);
|
|
704
1186
|
if (payload === null || payload === undefined) return; // silent
|
|
705
1187
|
|
|
706
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
//
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
//
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
}
|
|
731
|
-
} catch {
|
|
732
|
-
// resolveOpaqueSummaries swallows its own errors; this catch is belt
|
|
733
|
-
// + suspenders for any unexpected exception from the lib layer.
|
|
1188
|
+
// W2-1 (KT-DEC-0028): broad 全显示 — the legacy hint_broad_top_k hard cap is
|
|
1189
|
+
// retired. SessionStart must SEE every broad entry; scale is bounded by the
|
|
1190
|
+
// per-line char cap + broad_index_backstop fold, not by dropping entries.
|
|
1191
|
+
|
|
1192
|
+
// Block 5 (Option X): build both sinks via the shared renderer (same code
|
|
1193
|
+
// `fabric context` uses → byte-identical injection). Side-effect-free; the
|
|
1194
|
+
// emit + telemetry below stay in main().
|
|
1195
|
+
const { human, ai, resolvedPayload, hasRenderedContent, reminderToContext } =
|
|
1196
|
+
buildSessionStartSinks(cwd, payload, env);
|
|
1197
|
+
|
|
1198
|
+
// Nothing to say at all → silent exit (preserves the empty-payload contract).
|
|
1199
|
+
if (!hasRenderedContent) return;
|
|
1200
|
+
|
|
1201
|
+
// v2.2 dual-sink (Goal A / D7): emit both channels in one render. The human
|
|
1202
|
+
// systemMessage is gated by nudge_mode (emitHuman); the AI additionalContext
|
|
1203
|
+
// is emitted regardless. emitDualSink shapes the protocol per client (CC/Codex
|
|
1204
|
+
// camelCase nested envelope; unknown → stderr).
|
|
1205
|
+
if (!(env && env.skipStdout === true)) {
|
|
1206
|
+
emitDualSink(
|
|
1207
|
+
{ human, ai },
|
|
1208
|
+
{ client: detectClient(), eventName: "SessionStart", streams: { stdout: out, stderr: err } },
|
|
1209
|
+
);
|
|
1210
|
+
} else if (human !== null) {
|
|
1211
|
+
// skipStdout test seam: still surface the human breadcrumb to stderr.
|
|
1212
|
+
err.write(`${human}\n`);
|
|
734
1213
|
}
|
|
735
1214
|
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
if (recommendImport && fabricLanguageForEmit !== null) {
|
|
755
|
-
lines.push(renderBanner("broadImportBanner", fabricLanguageForEmit, {}));
|
|
1215
|
+
// v2.2 HK3-telemetry (W3-T1): record the injection side. We just OFFERED the
|
|
1216
|
+
// agent `resolvedPayload.entries` (the top_k-sliced broad menu); log their
|
|
1217
|
+
// ids so the true hit rate (consumed ÷ injected) is computable against the
|
|
1218
|
+
// consumption-side metrics.jsonl. Best-effort — never affects the emit.
|
|
1219
|
+
if (injectionLog !== null) {
|
|
1220
|
+
const injectedEntries = Array.isArray(resolvedPayload && resolvedPayload.entries)
|
|
1221
|
+
? resolvedPayload.entries
|
|
1222
|
+
: [];
|
|
1223
|
+
injectionLog.logInjection(cwd, {
|
|
1224
|
+
surface: "broad",
|
|
1225
|
+
stableIds: injectedEntries.map((e) => (e && e.id) || "").filter(Boolean),
|
|
1226
|
+
count: injectedEntries.length,
|
|
1227
|
+
revisionHash:
|
|
1228
|
+
resolvedPayload && typeof resolvedPayload.revision_hash === "string"
|
|
1229
|
+
? resolvedPayload.revision_hash
|
|
1230
|
+
: null,
|
|
1231
|
+
ts: nowMs,
|
|
1232
|
+
});
|
|
756
1233
|
}
|
|
757
1234
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
//
|
|
761
|
-
//
|
|
762
|
-
|
|
763
|
-
//
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
1235
|
+
// v2.2 dual-sink (Goal A): the legacy rc.33 W2-6 stdout JSON envelope is
|
|
1236
|
+
// replaced by emitDualSink above (which carries BOTH the human systemMessage
|
|
1237
|
+
// and the AI additionalContext, shaped per client). hint_reminder_to_context
|
|
1238
|
+
// still gates whether the AI sink is populated (see `ai` above).
|
|
1239
|
+
|
|
1240
|
+
// v2.1 NEW-N-3 (ADJ-NEWN-3): hook_surface_emitted instrumentation. One
|
|
1241
|
+
// best-effort ledger row recording WHICH broad-scoped ids were surfaced
|
|
1242
|
+
// into the session — the join key for measuring hook→behavior delta (did
|
|
1243
|
+
// the agent fab_recall what the hook surfaced?). SessionStart fires once
|
|
1244
|
+
// per session boot so this never bloats the ledger. Never blocks the hook
|
|
1245
|
+
// (KT-DEC-0007): any failure (no .fabric/, undetected client, write error)
|
|
1246
|
+
// degrades to silent skip. Client is omitted-by-skip when undetectable
|
|
1247
|
+
// because the schema's `client` enum admits only cc/codex.
|
|
1248
|
+
try {
|
|
1249
|
+
const surfaceClient = detectClient();
|
|
1250
|
+
const fabricDir = join(cwd, FABRIC_DIR_REL);
|
|
1251
|
+
if (surfaceClient !== undefined && existsSync(fabricDir)) {
|
|
1252
|
+
const renderedIds =
|
|
1253
|
+
resolvedPayload && Array.isArray(resolvedPayload.entries)
|
|
1254
|
+
? resolvedPayload.entries
|
|
1255
|
+
.map((e) => (e && typeof e.id === "string" ? e.id : null))
|
|
1256
|
+
.filter((x) => x !== null)
|
|
1257
|
+
: [];
|
|
1258
|
+
let idSuffix;
|
|
1259
|
+
try {
|
|
1260
|
+
idSuffix = require("node:crypto").randomUUID();
|
|
1261
|
+
} catch {
|
|
1262
|
+
idSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
772
1263
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
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).
|
|
789
|
-
for (const line of lines) {
|
|
790
|
-
err.write(`${line}\n`);
|
|
791
|
-
}
|
|
792
|
-
|
|
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
|
-
},
|
|
1264
|
+
const surfaceEvent = {
|
|
1265
|
+
kind: "fabric-event",
|
|
1266
|
+
id: `event:${idSuffix}`,
|
|
1267
|
+
ts: Date.now(),
|
|
1268
|
+
schema_version: 1,
|
|
1269
|
+
event_type: "hook_surface_emitted",
|
|
1270
|
+
hook_name: "knowledge-hint-broad",
|
|
1271
|
+
client: surfaceClient,
|
|
1272
|
+
target_channel: reminderToContext ? "stdout-additionalContext" : "stderr",
|
|
1273
|
+
rendered_ids: renderedIds,
|
|
1274
|
+
delivery_status: "delivered",
|
|
818
1275
|
};
|
|
819
|
-
|
|
820
|
-
} catch {
|
|
821
|
-
// Best-effort — stderr is the durable contract
|
|
1276
|
+
appendLockedLine(join(fabricDir, "events.jsonl"), JSON.stringify(surfaceEvent) + "\n");
|
|
822
1277
|
}
|
|
1278
|
+
} catch {
|
|
1279
|
+
// best-effort telemetry — never block session start
|
|
823
1280
|
}
|
|
824
1281
|
|
|
825
1282
|
// v2.0.0-rc.33 W2-5 (P1-8): record successful emit timestamp for the
|
|
@@ -836,6 +1293,7 @@ function main(env, stdio) {
|
|
|
836
1293
|
|
|
837
1294
|
module.exports = {
|
|
838
1295
|
main,
|
|
1296
|
+
buildSessionStartSinks,
|
|
839
1297
|
invokePlanContextHint,
|
|
840
1298
|
groupEntries,
|
|
841
1299
|
renderFull,
|
|
@@ -847,8 +1305,8 @@ module.exports = {
|
|
|
847
1305
|
readUnderseedThreshold,
|
|
848
1306
|
isImportTouched,
|
|
849
1307
|
shouldRecommendImport,
|
|
850
|
-
//
|
|
851
|
-
|
|
1308
|
+
// W2-1 (KT-DEC-0028) + rc.33 W2-5 / W2-6 helpers.
|
|
1309
|
+
readBroadIndexBackstop,
|
|
852
1310
|
readBroadCooldownHours,
|
|
853
1311
|
readReminderToContext,
|
|
854
1312
|
readBroadLastEmit,
|
|
@@ -865,7 +1323,7 @@ module.exports = {
|
|
|
865
1323
|
MATURITY_DRAFT,
|
|
866
1324
|
DEFAULT_UNDERSEED_NODE_THRESHOLD,
|
|
867
1325
|
KNOWLEDGE_CANONICAL_TYPES,
|
|
868
|
-
|
|
1326
|
+
DEFAULT_HINT_BROAD_INDEX_BACKSTOP,
|
|
869
1327
|
DEFAULT_HINT_BROAD_COOLDOWN_HOURS,
|
|
870
1328
|
DEFAULT_HINT_REMINDER_TO_CONTEXT,
|
|
871
1329
|
HINT_BROAD_LAST_EMIT_FILE_NAME,
|