@fenglimg/fabric-cli 2.2.0-rc.1 → 2.2.0-rc.11
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-AOE6AYI7.js → chunk-2KBCTMID.js} +31 -8
- package/dist/chunk-3D7B2UAZ.js +149 -0
- package/dist/{chunk-XC5RUHLK.js → chunk-3IOLS5EK.js} +23 -38
- package/dist/{plan-context-hint-FC6P3WFE.js → chunk-722JU5BP.js} +52 -12
- package/dist/{chunk-2R55HNVD.js → chunk-7ZDXBOOU.js} +234 -206
- package/dist/{doctor-YONYXDX6.js → chunk-E7HJUU34.js} +215 -52
- package/dist/chunk-EOT63RDH.js +36 -0
- package/dist/chunk-FNHDQTPC.js +16 -0
- package/dist/{chunk-2CY4BMTH.js → chunk-HORSMSZL.js} +9 -5
- package/dist/{chunk-BO4XIZWZ.js → chunk-NLNH64A3.js} +5 -18
- 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-XYRBZJDU.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 +133 -22
- package/dist/info-7FKBTMVO.js +139 -0
- package/dist/install-v2-WLEJ5XHT.js +3279 -0
- package/dist/{metrics-RER6NLFC.js → metrics-HMFH4YHK.js} +1 -1
- package/dist/{onboard-coverage-JWQWDZW7.js → onboard-coverage-XSG77LL3.js} +48 -27
- package/dist/plan-context-hint-5TNGH3R4.js +12 -0
- package/dist/{scope-explain-CDIZESP5.js → scope-explain-HLJZ2M33.js} +17 -6
- package/dist/status-4R3TM4FJ.js +37 -0
- package/dist/store-HOCORVL3.js +563 -0
- package/dist/{sync-UJ4BBCZJ.js → sync-DT5UJMMR.js} +197 -30
- package/dist/{uninstall-C3QXKOO6.js → uninstall-IFN2KYBK.js} +97 -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 +742 -259
- package/templates/hooks/knowledge-hint-broad.cjs +577 -274
- package/templates/hooks/knowledge-hint-narrow.cjs +113 -73
- package/templates/hooks/lib/banner-i18n.cjs +50 -1
- 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/nudge-policy.cjs +117 -0
- package/templates/hooks/lib/state-store.cjs +60 -0
- 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 +47 -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 +13 -3
- package/templates/skills/fabric-connect/SKILL.md +3 -3
- 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 +14 -5
- package/templates/skills/fabric-review/ref/cite-contract.md +1 -1
- 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 +1 -1
- package/templates/skills/fabric-sync/SKILL.md +1 -1
- package/templates/skills/lib/shared-policy.md +2 -2
- package/dist/chunk-4R2CYEA4.js +0 -116
- package/dist/chunk-L4Q55UC4.js +0 -52
- package/dist/chunk-LFIKMVY7.js +0 -27
- package/dist/chunk-RYAFBNES.js +0 -33
- package/dist/chunk-T5RPGCCM.js +0 -40
- package/dist/install-74ANPCCP.js +0 -2737
- package/dist/status-GLQWLWH6.js +0 -23
- package/dist/store-XB3ADT65.js +0 -144
- package/dist/whoami-2MLO4Y37.js +0 -36
- 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
|
@@ -75,15 +75,13 @@ const {
|
|
|
75
75
|
} = require("node:fs");
|
|
76
76
|
const { dirname, join } = require("node:path");
|
|
77
77
|
|
|
78
|
-
// rc.35
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
// `.fabric/.cache/summary-fallback.json` keyed by revision_hash.
|
|
82
|
-
const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
|
|
78
|
+
// KT-GLD-0006: the rc.35 opaque-summary substitution (resolveOpaqueSummaries) is
|
|
79
|
+
// retired — the write-time mechanical floor in extractKnowledge prevents
|
|
80
|
+
// degenerate summaries at the source, so the narrow hook no longer band-aids them.
|
|
83
81
|
// v2.0.0-rc.37 NEW-17: shared sidecar I/O for the plan-context-hint result
|
|
84
82
|
// cache (skips a redundant CLI cold-start spawn when the same path-set is
|
|
85
83
|
// re-edited within a session and the knowledge graph hasn't changed).
|
|
86
|
-
const {
|
|
84
|
+
const { readJsonStateAsync, writeJsonStateAsync } = require("./lib/state-store.cjs");
|
|
87
85
|
// W1-01 (ISS-011): the PreToolUse hook is the highest-frequency, most
|
|
88
86
|
// concurrency-exposed write surface in Fabric. Multi-window edits spawn
|
|
89
87
|
// concurrent hook processes that all append to the SAME non-session-scoped
|
|
@@ -91,6 +89,20 @@ const { readJsonState, writeJsonState } = require("./lib/state-store.cjs");
|
|
|
91
89
|
// and corrupt a line. Route every shared-file append through the advisory-lock
|
|
92
90
|
// primitive (drop-on-contention, best-effort — matches injection-log).
|
|
93
91
|
const { appendLockedLine } = require("./lib/injection-log.cjs");
|
|
92
|
+
// lifecycle-refactor W1-T2: client discriminator for the hook_surface_emitted
|
|
93
|
+
// event (schema requires the `client` enum). Mirrors the broad hook's import.
|
|
94
|
+
// v2.2 dual-sink (Goal A): + emitDualSink (PreToolUse two-channel emit).
|
|
95
|
+
const { detectClient, emitDualSink } = require("./lib/client-adapter.cjs");
|
|
96
|
+
// v2.2 dual-sink (Goal A / D4 + C5): human-output gate. On a narrow HIT the human
|
|
97
|
+
// systemMessage is gated by nudge_mode (a miss is already a silent early-return
|
|
98
|
+
// above); the AI additionalContext is emitted regardless (flow ⊥ observation).
|
|
99
|
+
// Optional require so an old install degrades to "always emit human".
|
|
100
|
+
let nudgePolicy = null;
|
|
101
|
+
try {
|
|
102
|
+
nudgePolicy = require("./lib/nudge-policy.cjs");
|
|
103
|
+
} catch {
|
|
104
|
+
// Lib missing (old install) — human sink always emits (legacy behavior).
|
|
105
|
+
}
|
|
94
106
|
// v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
|
|
95
107
|
// resolved-bindings snapshot. Store-aware hint surfaces the write-target store
|
|
96
108
|
// for the edited file WITHOUT re-resolving or walking store trees. Best-effort.
|
|
@@ -239,8 +251,8 @@ function readPayload(rawStdin) {
|
|
|
239
251
|
} catch (e) {
|
|
240
252
|
// v2.0.0-rc.29 REVIEW (codex LOW-1): apply BUG-L1's malformed-input
|
|
241
253
|
// diagnostic uniformly across hook scripts. fabric-hint.cjs got the stderr
|
|
242
|
-
// trace in TASK-008; without this matching write here, a broken Codex
|
|
243
|
-
//
|
|
254
|
+
// trace in TASK-008; without this matching write here, a broken Codex
|
|
255
|
+
// host payload silently kills the narrow hint with no operator
|
|
244
256
|
// signal at all. Best-effort: a failed stderr write must not throw upward
|
|
245
257
|
// (hook contract — never crash the host's edit pipeline).
|
|
246
258
|
try {
|
|
@@ -254,32 +266,27 @@ function readPayload(rawStdin) {
|
|
|
254
266
|
}
|
|
255
267
|
|
|
256
268
|
/**
|
|
257
|
-
* Extract the tool name from a hook payload.
|
|
258
|
-
*
|
|
269
|
+
* Extract the tool name from a hook payload. Both supported clients use the
|
|
270
|
+
* same shape:
|
|
259
271
|
* - Claude Code: { tool_name, tool_input: { ... } }
|
|
260
272
|
* - Codex CLI: { tool_name, tool_input: { ... } } (mirrors Claude)
|
|
261
|
-
* - Cursor: { tool, input: { ... } } (legacy variant)
|
|
262
273
|
* Returns null when no recognizable shape is present.
|
|
263
274
|
*/
|
|
264
275
|
function extractToolName(payload) {
|
|
265
276
|
if (!payload || typeof payload !== "object") return null;
|
|
266
277
|
if (typeof payload.tool_name === "string") return payload.tool_name;
|
|
267
|
-
if (typeof payload.tool === "string") return payload.tool;
|
|
268
278
|
return null;
|
|
269
279
|
}
|
|
270
280
|
|
|
271
281
|
/**
|
|
272
|
-
* Extract the tool_input object from a hook payload
|
|
273
|
-
*
|
|
282
|
+
* Extract the tool_input object from a hook payload (the `tool_input`
|
|
283
|
+
* convention shared by Claude Code and Codex CLI).
|
|
274
284
|
*/
|
|
275
285
|
function extractToolInput(payload) {
|
|
276
286
|
if (!payload || typeof payload !== "object") return null;
|
|
277
287
|
if (payload.tool_input && typeof payload.tool_input === "object") {
|
|
278
288
|
return payload.tool_input;
|
|
279
289
|
}
|
|
280
|
-
if (payload.input && typeof payload.input === "object") {
|
|
281
|
-
return payload.input;
|
|
282
|
-
}
|
|
283
290
|
return null;
|
|
284
291
|
}
|
|
285
292
|
|
|
@@ -289,8 +296,8 @@ function extractToolInput(payload) {
|
|
|
289
296
|
* - bulk variant: { file_paths: ["src/foo.ts", "src/bar.ts"] }
|
|
290
297
|
* - MultiEdit: { file_path: "...", edits: [{file_path?, ...}, ...] }
|
|
291
298
|
* (Claude Code's MultiEdit currently issues per-edit operations against
|
|
292
|
-
* a single `file_path`; older drafts
|
|
293
|
-
*
|
|
299
|
+
* a single `file_path`; older drafts carried per-edit `file_path`. We
|
|
300
|
+
* accept both to be defensive.)
|
|
294
301
|
*
|
|
295
302
|
* Returns a deduped array of strings — empty when no path is recognizable.
|
|
296
303
|
* Order: first occurrence wins (stable across re-renders of the same payload).
|
|
@@ -520,7 +527,7 @@ function appendHintSilenceCounter(projectRoot, now) {
|
|
|
520
527
|
/**
|
|
521
528
|
* Resolve the session id used to key the cache file. Priority:
|
|
522
529
|
* 1. payload.session_id (string, non-empty) — preferred; threads through
|
|
523
|
-
* from the client hook payload (Claude Code / Codex CLI
|
|
530
|
+
* from the client hook payload (Claude Code / Codex CLI).
|
|
524
531
|
* 2. process.env.FABRIC_SESSION_ID — environment fallback.
|
|
525
532
|
* 3. SYNTHETIC_SESSION_ID — a process-lifetime UUID, generated lazily so
|
|
526
533
|
* tests can stub it (see resetSyntheticSessionId).
|
|
@@ -862,9 +869,9 @@ function pathSetKey(paths) {
|
|
|
862
869
|
|
|
863
870
|
// Returns the cached cliPayload for `paths` iff the cache's meta token matches
|
|
864
871
|
// the current knowledge-graph freshness, else null (caller spawns the CLI).
|
|
865
|
-
function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
|
|
872
|
+
async function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
|
|
866
873
|
if (metaToken === null) return null;
|
|
867
|
-
const cache =
|
|
874
|
+
const cache = await readJsonStateAsync(
|
|
868
875
|
cwd,
|
|
869
876
|
narrowResultCacheFileName(sessionId),
|
|
870
877
|
(parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
|
|
@@ -877,10 +884,10 @@ function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
|
|
|
877
884
|
// Persist `cliPayload` under the path-set key. Resets the map when the meta
|
|
878
885
|
// token changed (stale graph) and caps the map size (FIFO-ish: drop oldest
|
|
879
886
|
// insertion-order keys). Best-effort — never throws.
|
|
880
|
-
function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
|
|
887
|
+
async function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
|
|
881
888
|
if (metaToken === null) return;
|
|
882
889
|
const fileName = narrowResultCacheFileName(sessionId);
|
|
883
|
-
const prior =
|
|
890
|
+
const prior = await readJsonStateAsync(
|
|
884
891
|
cwd,
|
|
885
892
|
fileName,
|
|
886
893
|
(parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
|
|
@@ -894,7 +901,7 @@ function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
|
|
|
894
901
|
delete results[stale];
|
|
895
902
|
}
|
|
896
903
|
}
|
|
897
|
-
|
|
904
|
+
await writeJsonStateAsync(cwd, fileName, { meta_token: metaToken, results });
|
|
898
905
|
}
|
|
899
906
|
|
|
900
907
|
// -----------------------------------------------------------------------------
|
|
@@ -1169,7 +1176,15 @@ function formatEntryLine(entry, maxLen) {
|
|
|
1169
1176
|
const maturity = entry.maturity || "unknown";
|
|
1170
1177
|
const summary = truncateSummary(entry.summary, maxLen);
|
|
1171
1178
|
const tail = summary.length > 0 ? ` ${summary}` : "";
|
|
1172
|
-
|
|
1179
|
+
// lifecycle-refactor W3-T2 (§7 图谱消费 / §5 hook 沿 related 二阶召回): mark entries
|
|
1180
|
+
// pulled in by a surfaced entry's one-hop `related` graph edge with their source
|
|
1181
|
+
// provenance. Omitted for ordinarily-ranked entries — no fake graph annotation
|
|
1182
|
+
// is ever synthesized (graph-empty honesty).
|
|
1183
|
+
const provenance =
|
|
1184
|
+
typeof entry.related_to === "string" && entry.related_to.length > 0
|
|
1185
|
+
? ` (related-to-${entry.related_to})`
|
|
1186
|
+
: "";
|
|
1187
|
+
return ` [${id}] (${type}/${maturity})${tail}${provenance}`;
|
|
1173
1188
|
}
|
|
1174
1189
|
|
|
1175
1190
|
function readSummaryMaxLen(projectRoot) {
|
|
@@ -1235,7 +1250,7 @@ function renderSummary(payload, maxLen) {
|
|
|
1235
1250
|
// Main — invoked as a CLI (require.main === module) and in-process by tests
|
|
1236
1251
|
// -----------------------------------------------------------------------------
|
|
1237
1252
|
|
|
1238
|
-
function main(env, stdio) {
|
|
1253
|
+
async function main(env, stdio) {
|
|
1239
1254
|
try {
|
|
1240
1255
|
const cwd = (env && env.cwd) || process.cwd();
|
|
1241
1256
|
const now = (env && env.now) || new Date();
|
|
@@ -1337,14 +1352,14 @@ function main(env, stdio) {
|
|
|
1337
1352
|
const useResultCache = !(env && env.skipResultCache === true);
|
|
1338
1353
|
const metaToken = useResultCache ? metaFreshnessToken(cwd) : null;
|
|
1339
1354
|
const cached = useResultCache
|
|
1340
|
-
? readNarrowResultCache(cwd, sessionId, paths, metaToken)
|
|
1355
|
+
? await readNarrowResultCache(cwd, sessionId, paths, metaToken)
|
|
1341
1356
|
: null;
|
|
1342
1357
|
if (cached !== null) {
|
|
1343
1358
|
cliPayload = cached;
|
|
1344
1359
|
} else {
|
|
1345
1360
|
cliPayload = invokePlanContextHint(cwd, paths);
|
|
1346
1361
|
if (useResultCache && cliPayload !== null && cliPayload !== undefined) {
|
|
1347
|
-
writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload);
|
|
1362
|
+
await writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload);
|
|
1348
1363
|
}
|
|
1349
1364
|
}
|
|
1350
1365
|
}
|
|
@@ -1475,21 +1490,10 @@ function main(env, stdio) {
|
|
|
1475
1490
|
}
|
|
1476
1491
|
|
|
1477
1492
|
const summaryMaxLen = readSummaryMaxLen(cwd);
|
|
1478
|
-
// rc.35
|
|
1479
|
-
//
|
|
1480
|
-
//
|
|
1481
|
-
|
|
1482
|
-
let resolvedEntries = dedupDecision.filtered;
|
|
1483
|
-
try {
|
|
1484
|
-
resolvedEntries = resolveOpaqueSummaries(
|
|
1485
|
-
dedupDecision.filtered,
|
|
1486
|
-
cwd,
|
|
1487
|
-
currentRevisionHash,
|
|
1488
|
-
);
|
|
1489
|
-
} catch {
|
|
1490
|
-
// resolveOpaqueSummaries swallows its own errors; defensive catch.
|
|
1491
|
-
}
|
|
1492
|
-
const lines = renderSummary({ ...cliPayload, entries: resolvedEntries }, summaryMaxLen);
|
|
1493
|
+
// KT-GLD-0006: the rc.35 opaque-summary runtime substitution is retired — the
|
|
1494
|
+
// write-time mechanical floor in extractKnowledge prevents degenerate summaries
|
|
1495
|
+
// at the source, so the narrow hook renders the description summary as-is.
|
|
1496
|
+
const lines = renderSummary({ ...cliPayload, entries: dedupDecision.filtered }, summaryMaxLen);
|
|
1493
1497
|
if (lines.length === 0) return;
|
|
1494
1498
|
|
|
1495
1499
|
// v2.1.0-rc.1 P4 (F4/S63): store-aware hint — append the write-target store
|
|
@@ -1511,39 +1515,76 @@ function main(env, stdio) {
|
|
|
1511
1515
|
}
|
|
1512
1516
|
}
|
|
1513
1517
|
|
|
1514
|
-
//
|
|
1515
|
-
|
|
1516
|
-
|
|
1518
|
+
// v2.2 dual-sink (Goal A / C5): a narrow HIT emits BOTH channels. The human
|
|
1519
|
+
// systemMessage is gated by nudge_mode (a MISS already returned silently far
|
|
1520
|
+
// above — narrow.length===0 / gate-skip / dedup-filter); the AI
|
|
1521
|
+
// additionalContext is emitted regardless (gated only by reminder_to_context),
|
|
1522
|
+
// preserving flow ⊥ observation (D5). emitDualSink shapes the protocol per
|
|
1523
|
+
// client (CC/Codex camelCase nested; unknown → stderr).
|
|
1524
|
+
const text = lines.join("\n");
|
|
1525
|
+
const humanGate =
|
|
1526
|
+
nudgePolicy !== null
|
|
1527
|
+
? nudgePolicy.resolveHumanSink(cwd, "pre_tool_use", { hit: true })
|
|
1528
|
+
: { emitHuman: true };
|
|
1529
|
+
const human = humanGate.emitHuman ? text : null;
|
|
1530
|
+
const ai = readReminderToContext(cwd) ? text : null;
|
|
1531
|
+
if (!(env && env.skipStdout === true)) {
|
|
1532
|
+
emitDualSink(
|
|
1533
|
+
{ human, ai },
|
|
1534
|
+
{ client: detectClient(), eventName: "PreToolUse", streams: { stdout: out, stderr: err } },
|
|
1535
|
+
);
|
|
1536
|
+
} else if (human !== null) {
|
|
1537
|
+
// skipStdout test seam: still surface the human breadcrumb to stderr.
|
|
1538
|
+
err.write(`${text}\n`);
|
|
1517
1539
|
}
|
|
1518
1540
|
|
|
1519
|
-
//
|
|
1520
|
-
//
|
|
1521
|
-
//
|
|
1522
|
-
//
|
|
1523
|
-
//
|
|
1524
|
-
//
|
|
1525
|
-
//
|
|
1526
|
-
//
|
|
1527
|
-
//
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
const
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1541
|
+
// lifecycle-refactor W1-T2: hook_surface_emitted — record WHICH narrow-scoped
|
|
1542
|
+
// stable_ids this PreToolUse fire surfaced into the edit, so doctor can join
|
|
1543
|
+
// surfaced→edited (this is the join's LEFT half; the edit_intent_checked event
|
|
1544
|
+
// appended above supplies the edited path / RIGHT half, keyed on the same
|
|
1545
|
+
// real payload session_id). Fires only after all gates passed and lines were
|
|
1546
|
+
// rendered (so it tracks genuinely-surfaced hints, never bloat). Best-effort,
|
|
1547
|
+
// never blocks the edit (KT-DEC-0007); the schema's `client` is a required
|
|
1548
|
+
// enum, so skip when the client is undetectable rather than emit an invalid
|
|
1549
|
+
// row. Mirrors the broad SessionStart emit (knowledge-hint-broad.cjs).
|
|
1550
|
+
try {
|
|
1551
|
+
const surfaceClient = detectClient();
|
|
1552
|
+
const fabricDir = join(cwd, FABRIC_DIR_REL);
|
|
1553
|
+
if (surfaceClient !== undefined && existsSync(fabricDir)) {
|
|
1554
|
+
const renderedIds = dedupDecision.filtered
|
|
1555
|
+
.map((e) => (e && typeof e.id === "string" ? e.id : null))
|
|
1556
|
+
.filter((x) => x !== null);
|
|
1557
|
+
const realSessionId =
|
|
1558
|
+
payload &&
|
|
1559
|
+
typeof payload === "object" &&
|
|
1560
|
+
typeof payload.session_id === "string" &&
|
|
1561
|
+
payload.session_id.length > 0
|
|
1562
|
+
? payload.session_id
|
|
1563
|
+
: null;
|
|
1564
|
+
const surfaceEvent = {
|
|
1565
|
+
kind: "fabric-event",
|
|
1566
|
+
id: `event:${randomUUID()}`,
|
|
1567
|
+
ts: nowMs,
|
|
1568
|
+
schema_version: 1,
|
|
1569
|
+
...(realSessionId ? { session_id: realSessionId } : {}),
|
|
1570
|
+
event_type: "hook_surface_emitted",
|
|
1571
|
+
hook_name: "knowledge-hint-narrow",
|
|
1572
|
+
client: surfaceClient,
|
|
1573
|
+
target_channel: "stderr",
|
|
1574
|
+
rendered_ids: renderedIds,
|
|
1575
|
+
delivery_status: "delivered",
|
|
1540
1576
|
};
|
|
1541
|
-
|
|
1542
|
-
} catch {
|
|
1543
|
-
// Best-effort — stderr is the durable contract.
|
|
1577
|
+
appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), JSON.stringify(surfaceEvent) + "\n");
|
|
1544
1578
|
}
|
|
1579
|
+
} catch {
|
|
1580
|
+
// best-effort telemetry — never block the edit
|
|
1545
1581
|
}
|
|
1546
1582
|
|
|
1583
|
+
// v2.2 dual-sink (Goal A): the legacy rc.33 W2-6 CC-only stdout envelope is
|
|
1584
|
+
// replaced by emitDualSink above (which carries BOTH the human systemMessage
|
|
1585
|
+
// and the AI additionalContext, shaped per client). reminder_to_context still
|
|
1586
|
+
// gates whether the AI sink is populated (see `ai` above).
|
|
1587
|
+
|
|
1547
1588
|
// v2.0.0-rc.33 W2-5: record successful emit for cooldown gate.
|
|
1548
1589
|
if (cooldownHours > 0 && !(env && env.skipCooldownWrite === true)) {
|
|
1549
1590
|
writeNarrowLastEmit(cwd, nowMs);
|
|
@@ -1631,6 +1672,5 @@ if (require.main === module) {
|
|
|
1631
1672
|
main(
|
|
1632
1673
|
{ cwd: process.cwd(), now: new Date(), stdin: stdinRaw },
|
|
1633
1674
|
{ stderr: process.stderr },
|
|
1634
|
-
);
|
|
1635
|
-
process.exit(0);
|
|
1675
|
+
).finally(() => process.exit(0));
|
|
1636
1676
|
}
|
|
@@ -28,8 +28,9 @@
|
|
|
28
28
|
*
|
|
29
29
|
* - STRINGS — exported for test introspection only (read-only by convention).
|
|
30
30
|
*
|
|
31
|
-
* Banner keys
|
|
31
|
+
* Banner keys:
|
|
32
32
|
* Signal A (archive): archiveLine1, archiveActivity, archiveCta
|
|
33
|
+
* Archive backlog: backlogLine1, backlogCta
|
|
33
34
|
* Signal B (review): reviewLine1, reviewCta
|
|
34
35
|
* Signal C (import): importLine1, importCta
|
|
35
36
|
* Signal D (maintenance): maintenanceLine1Never, maintenanceLine1Aged, maintenanceLine2
|
|
@@ -165,6 +166,23 @@ const STRINGS = {
|
|
|
165
166
|
"zh-CN-hybrid": () => " 是否调 /fabric-archive 检查值得归档的决策/踩坑/复用?",
|
|
166
167
|
},
|
|
167
168
|
|
|
169
|
+
// ---- Archive backlog (cross-session safety net, crack 2) ------------------
|
|
170
|
+
// Replaces the old global-24h archive timer: counts DEAD sessions (session
|
|
171
|
+
// ended / idle) carrying unarchived high-value work. Substring "${count}" is
|
|
172
|
+
// addressable for tests. params: { count: number }
|
|
173
|
+
backlogLine1: {
|
|
174
|
+
"zh-CN": (p) => `📋 Fabric: ${p.count} 个已结束的会话有未归档的高价值改动。`,
|
|
175
|
+
en: (p) => `📋 Fabric: ${p.count} ended session(s) carry unarchived high-value work.`,
|
|
176
|
+
"zh-CN-hybrid": (p) => `📋 Fabric: ${p.count} 个已结束的会话有未归档的高价值改动。`,
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
// params: {} — protected token /fabric-archive verbatim across all variants.
|
|
180
|
+
backlogCta: {
|
|
181
|
+
"zh-CN": () => " 是否调 /fabric-archive 跨会话补归档这些遗漏?",
|
|
182
|
+
en: () => " Run /fabric-archive to sweep these missed sessions across the backlog?",
|
|
183
|
+
"zh-CN-hybrid": () => " 是否调 /fabric-archive 跨会话补归档这些遗漏?",
|
|
184
|
+
},
|
|
185
|
+
|
|
168
186
|
// ---- Signal B: review -----------------------------------------------------
|
|
169
187
|
// Source (zh-CN): fabric-hint.cjs:651 `📋 Fabric: 已积累 ${stats.count} 条待审核知识${ageSuffix}。`
|
|
170
188
|
// params: { count, ageSuffix } — ageSuffix is " / 最早一条 N.N 天前" or "" (zh-CN only)
|
|
@@ -238,6 +256,37 @@ const STRINGS = {
|
|
|
238
256
|
"zh-CN-hybrid": () => " 是否调 `fabric doctor --lint` 看看知识库健康度?",
|
|
239
257
|
},
|
|
240
258
|
|
|
259
|
+
// ---- Stop hook: session-activity status (human trust anchor) -------------
|
|
260
|
+
// Observability grill (a): a no-signal Stop currently returns SILENT — the
|
|
261
|
+
// human only ever hears from Fabric when there is a nudge to act on, never a
|
|
262
|
+
// "here is what I did" status, which reads as "Fabric does nothing". This line
|
|
263
|
+
// is the trust anchor: session-scoped counts from events.jsonl (edits +
|
|
264
|
+
// knowledge pulls by the AI + pending backlog). Cadence is gated by nudge_mode
|
|
265
|
+
// (silent=never, normal=once/session, verbose=every turn) at the call site.
|
|
266
|
+
// params: { edits, consumed, pending } — all numbers.
|
|
267
|
+
statusLine: {
|
|
268
|
+
"zh-CN": (p) =>
|
|
269
|
+
`📋 Fabric 本会话 · 改 ${p.edits} 文件 · AI 取用知识 ${p.consumed} 次 · 待审 ${p.pending} 条`,
|
|
270
|
+
en: (p) =>
|
|
271
|
+
`📋 Fabric this session · ${p.edits} files edited · ${p.consumed} KB pulls by AI · ${p.pending} pending`,
|
|
272
|
+
"zh-CN-hybrid": (p) =>
|
|
273
|
+
`📋 Fabric 本会话 · 改 ${p.edits} 文件 · AI 取用知识 ${p.consumed} 次 · 待审 ${p.pending} 条`,
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
// ---- Stop hook: nudge_mode tier guidance (discoverability) ---------------
|
|
277
|
+
// Observability grill (Q4): users did not know the human-channel volume knob
|
|
278
|
+
// (nudge_mode) exists, so they assumed the hooks never surface to humans. This
|
|
279
|
+
// line names the current tier and the levers. params: { mode } — current
|
|
280
|
+
// nudge_mode. Protected token: nudge_mode + the config path verbatim.
|
|
281
|
+
statusTier: {
|
|
282
|
+
"zh-CN": (p) =>
|
|
283
|
+
` 音量 ${p.mode}:verbose=每步可见 / silent=静音(.fabric/fabric-config.json nudge_mode)`,
|
|
284
|
+
en: (p) =>
|
|
285
|
+
` volume ${p.mode}: verbose=show every step / silent=mute (.fabric/fabric-config.json nudge_mode)`,
|
|
286
|
+
"zh-CN-hybrid": (p) =>
|
|
287
|
+
` 音量 ${p.mode}:verbose=每步可见 / silent=静音(.fabric/fabric-config.json nudge_mode)`,
|
|
288
|
+
},
|
|
289
|
+
|
|
241
290
|
// ---- Broad hook: import recommendation ------------------------------------
|
|
242
291
|
// Source (zh-CN): knowledge-hint-broad.cjs:262
|
|
243
292
|
// " 📋 Fabric: 知识库稀疏,是否调 /fabric-import 从 git 历史与现有文档回灌知识?"
|
|
@@ -4,37 +4,50 @@
|
|
|
4
4
|
// Hooks are a REMINDER layer (KT-DEC-0007) and must never block. They are also
|
|
5
5
|
// FORBIDDEN from re-resolving stores or walking `.fabric` store trees directly
|
|
6
6
|
// — a hook reads ONLY the CLI-pre-generated snapshot at
|
|
7
|
-
// `~/.fabric/state/bindings/<
|
|
7
|
+
// `~/.fabric/state/bindings/<workspace_binding_id>_resolved.json` (written by P3
|
|
8
8
|
// install/sync/bind). This keeps the resolver logic in one place (the CLI) and
|
|
9
9
|
// keeps hooks a thin, store-unaware-by-construction projection. Missing /
|
|
10
10
|
// unreadable / malformed snapshot → null (harmless degrade; the hook proceeds
|
|
11
11
|
// without store labels). Zero-dep CJS so it inline-loads at hook runtime.
|
|
12
12
|
|
|
13
|
-
const { existsSync, readFileSync } = require("node:fs");
|
|
13
|
+
const { existsSync, readFileSync, readdirSync, statSync } = require("node:fs");
|
|
14
14
|
const { join } = require("node:path");
|
|
15
15
|
const { homedir } = require("node:os");
|
|
16
16
|
|
|
17
|
+
// Canonical knowledge type dirs (mirror STORE_KNOWLEDGE_TYPE_DIRS in
|
|
18
|
+
// packages/shared/src/schemas/store.ts). Kept inline — this zero-dep reader
|
|
19
|
+
// runs in user repos without node_modules access.
|
|
20
|
+
const KNOWLEDGE_CANONICAL_TYPES = [
|
|
21
|
+
"decisions",
|
|
22
|
+
"pitfalls",
|
|
23
|
+
"guidelines",
|
|
24
|
+
"models",
|
|
25
|
+
"processes",
|
|
26
|
+
];
|
|
27
|
+
const KNOWLEDGE_SUBDIR = "knowledge";
|
|
28
|
+
const PENDING_SUBDIR = "pending";
|
|
29
|
+
|
|
17
30
|
// `~/.fabric` (FABRIC_HOME override mirrors the CLI's resolveGlobalRoot).
|
|
18
31
|
function resolveGlobalRoot() {
|
|
19
32
|
return join(process.env.FABRIC_HOME || homedir(), ".fabric");
|
|
20
33
|
}
|
|
21
34
|
|
|
22
|
-
function bindingsSnapshotPath(
|
|
35
|
+
function bindingsSnapshotPath(bindingId, globalRoot) {
|
|
23
36
|
return join(
|
|
24
37
|
globalRoot || resolveGlobalRoot(),
|
|
25
38
|
"state",
|
|
26
39
|
"bindings",
|
|
27
|
-
|
|
40
|
+
bindingId + "_resolved.json",
|
|
28
41
|
);
|
|
29
42
|
}
|
|
30
43
|
|
|
31
44
|
// Read + shallow-validate the snapshot. Returns the parsed object, or null when
|
|
32
45
|
// absent / unreadable / not the expected shape. NEVER throws.
|
|
33
|
-
function readBindingsSnapshot(
|
|
34
|
-
if (typeof
|
|
46
|
+
function readBindingsSnapshot(bindingId, globalRoot) {
|
|
47
|
+
if (typeof bindingId !== "string" || bindingId.length === 0) {
|
|
35
48
|
return null;
|
|
36
49
|
}
|
|
37
|
-
const path = bindingsSnapshotPath(
|
|
50
|
+
const path = bindingsSnapshotPath(bindingId, globalRoot);
|
|
38
51
|
if (!existsSync(path)) {
|
|
39
52
|
return null;
|
|
40
53
|
}
|
|
@@ -54,6 +67,103 @@ function readBindingsSnapshot(projectId, globalRoot) {
|
|
|
54
67
|
}
|
|
55
68
|
}
|
|
56
69
|
|
|
70
|
+
// Recursively count *.md files under `dir`, tracking the oldest mtime. Missing
|
|
71
|
+
// / unreadable dirs contribute zero (degrade silently — a hook never throws).
|
|
72
|
+
function countMarkdownFiles(dir) {
|
|
73
|
+
let count = 0;
|
|
74
|
+
let oldestMtimeMs = null;
|
|
75
|
+
let entries;
|
|
76
|
+
try {
|
|
77
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
78
|
+
} catch {
|
|
79
|
+
return { count, oldestMtimeMs };
|
|
80
|
+
}
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const fullPath = join(dir, entry.name);
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
const nested = countMarkdownFiles(fullPath);
|
|
85
|
+
count += nested.count;
|
|
86
|
+
if (
|
|
87
|
+
nested.oldestMtimeMs !== null &&
|
|
88
|
+
(oldestMtimeMs === null || nested.oldestMtimeMs < oldestMtimeMs)
|
|
89
|
+
) {
|
|
90
|
+
oldestMtimeMs = nested.oldestMtimeMs;
|
|
91
|
+
}
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
let mtimeMs;
|
|
98
|
+
try {
|
|
99
|
+
mtimeMs = statSync(fullPath).mtimeMs;
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
count += 1;
|
|
104
|
+
if (oldestMtimeMs === null || mtimeMs < oldestMtimeMs) {
|
|
105
|
+
oldestMtimeMs = mtimeMs;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { count, oldestMtimeMs };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// LIVE store-backed knowledge counts for nudges (underseed canonical_count,
|
|
112
|
+
// review-backlog pending_count). The snapshot's cached `knowledge_stats` is a
|
|
113
|
+
// store-global projection frozen at write time, so it goes stale whenever store
|
|
114
|
+
// content changes out-of-band (a `git pull` in the store repo, a sync run from a
|
|
115
|
+
// *different* bound workspace) — that staleness is the root cause of the phantom
|
|
116
|
+
// review-backlog (KT-PIT-0017) and the false "knowledge sparse" underseed nudge.
|
|
117
|
+
//
|
|
118
|
+
// Fix: the snapshot persists the resolved store ROOT dirs (`knowledge_store_dirs`,
|
|
119
|
+
// stable across content sync — they only change when mounts/bindings change,
|
|
120
|
+
// which regenerates the snapshot). Recount the *.md files under those dirs LIVE
|
|
121
|
+
// so the numbers are always fresh regardless of how content changed. Falls back
|
|
122
|
+
// to the cached `knowledge_stats` for snapshots written before this field
|
|
123
|
+
// existed. Returns null only when neither source is available.
|
|
124
|
+
function liveKnowledgeStats(snapshot) {
|
|
125
|
+
if (!snapshot || typeof snapshot !== "object") {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const dirs = snapshot.knowledge_store_dirs;
|
|
129
|
+
if (Array.isArray(dirs) && dirs.length > 0) {
|
|
130
|
+
let pendingCount = 0;
|
|
131
|
+
let canonicalCount = 0;
|
|
132
|
+
let oldestPendingMtimeMs = null;
|
|
133
|
+
for (const storeDir of dirs) {
|
|
134
|
+
if (typeof storeDir !== "string" || storeDir.length === 0) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
for (const type of KNOWLEDGE_CANONICAL_TYPES) {
|
|
138
|
+
canonicalCount += countMarkdownFiles(join(storeDir, KNOWLEDGE_SUBDIR, type)).count;
|
|
139
|
+
}
|
|
140
|
+
const pending = countMarkdownFiles(join(storeDir, KNOWLEDGE_SUBDIR, PENDING_SUBDIR));
|
|
141
|
+
pendingCount += pending.count;
|
|
142
|
+
if (
|
|
143
|
+
pending.oldestMtimeMs !== null &&
|
|
144
|
+
(oldestPendingMtimeMs === null || pending.oldestMtimeMs < oldestPendingMtimeMs)
|
|
145
|
+
) {
|
|
146
|
+
oldestPendingMtimeMs = pending.oldestMtimeMs;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { pendingCount, canonicalCount, oldestPendingMtimeMs };
|
|
150
|
+
}
|
|
151
|
+
// #3 (GH issue): snapshot predates knowledge_store_dirs. The cached
|
|
152
|
+
// `knowledge_stats` projection is frozen at snapshot-write time and goes stale
|
|
153
|
+
// out-of-band (store grew via git pull / cross-workspace sync), so trusting it
|
|
154
|
+
// re-introduced exactly the false-nudge this whole field cures — observed a
|
|
155
|
+
// store with 61 live canonical entries whose cached count was frozen at 1,
|
|
156
|
+
// mis-firing the "knowledge sparse → /fabric-import" underseed nudge AND
|
|
157
|
+
// defeating the fabric-import `canonical > 50 → SKIP` guard. read_set carries
|
|
158
|
+
// no resolved store root either (alias/uuid only), so a live recount is
|
|
159
|
+
// impossible without re-resolution (which hooks must not do). Return null
|
|
160
|
+
// ("undeterminable") so callers SKIP the nudge rather than act on a stale
|
|
161
|
+
// count — old snapshots self-heal on the next install/sync/store-op (which
|
|
162
|
+
// regenerates the snapshot WITH knowledge_store_dirs). 宁可不弹也别误弹
|
|
163
|
+
// (KT-DEC-0007: hook = nudge, never a false-positive gate).
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
|
|
57
167
|
// Render a compact, per-store label line for a SessionStart / Stop hook from a
|
|
58
168
|
// snapshot. Empty string when there is nothing to show (degrade silently). The
|
|
59
169
|
// label is provenance only — it never re-resolves; it just echoes the read-set
|
|
@@ -77,5 +187,6 @@ module.exports = {
|
|
|
77
187
|
resolveGlobalRoot,
|
|
78
188
|
bindingsSnapshotPath,
|
|
79
189
|
readBindingsSnapshot,
|
|
190
|
+
liveKnowledgeStats,
|
|
80
191
|
formatStoreLabels,
|
|
81
192
|
};
|
|
@@ -15,13 +15,12 @@
|
|
|
15
15
|
// hand-syncing is cheaper than introducing transpile machinery.
|
|
16
16
|
// - The existing `installHookLibs` pipeline auto-copies every `.cjs` under
|
|
17
17
|
// templates/hooks/lib/ to each client's hooks/lib/ dir, so this file
|
|
18
|
-
// auto-ships to cc/codex
|
|
18
|
+
// auto-ships to cc/codex with no install pipeline change.
|
|
19
19
|
//
|
|
20
20
|
// Vocabulary contract (mirrored 1:1 with the TS source):
|
|
21
|
-
// - cite_tags enum: applied | dismissed | none (
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
// longer emitted verbatim, so the TS source and this twin stay in lockstep.
|
|
21
|
+
// - cite_tags enum: applied | dismissed | none (2-state vocab). Pre-user
|
|
22
|
+
// clean-slate: unrecognized tags degrade to `none` (no legacy remap), so
|
|
23
|
+
// the TS source and this twin stay in lockstep.
|
|
25
24
|
// - operator kinds: edit | not_edit | require | forbid
|
|
26
25
|
// (source token `!edit:` → schema kind `not_edit`)
|
|
27
26
|
// - skip:<reason> captures everything after the first colon, so
|
|
@@ -52,24 +51,17 @@ function splitStorePrefix(token) {
|
|
|
52
51
|
: { store: token.slice(0, colon), id: token.slice(colon + 1) };
|
|
53
52
|
}
|
|
54
53
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// as input but emitted as the 2-state vocab so cite-coverage never undercounts.
|
|
58
|
-
const LEGACY_CITE_TAG_REMAP = {
|
|
59
|
-
planned: "applied",
|
|
60
|
-
recalled: "applied",
|
|
61
|
-
"chained-from": "applied",
|
|
62
|
-
};
|
|
63
|
-
|
|
54
|
+
// Mirrors normalizeCiteTag in the TS source: applied/dismissed/none pass
|
|
55
|
+
// through; anything else degrades to `none` (no legacy remap).
|
|
64
56
|
function parseTag(rawTag) {
|
|
65
57
|
if (!rawTag) return "none";
|
|
66
|
-
// Tags may carry tails like `
|
|
67
|
-
//
|
|
58
|
+
// Tags may carry tails like `dismissed:scope-mismatch`; the head token
|
|
59
|
+
// (whitespace/colon-bounded) wins.
|
|
68
60
|
const head = rawTag.trim().split(/[\s:]+/)[0].toLowerCase();
|
|
69
61
|
if (head === "applied" || head === "dismissed" || head === "none") {
|
|
70
62
|
return head;
|
|
71
63
|
}
|
|
72
|
-
return
|
|
64
|
+
return "none";
|
|
73
65
|
}
|
|
74
66
|
|
|
75
67
|
function parseContractTail(tail) {
|
|
@@ -166,9 +158,9 @@ function parseCiteLine(raw) {
|
|
|
166
158
|
// v2.0.0-rc.27.1 (Codex review fix): cite_commitments MUST be index-
|
|
167
159
|
// aligned with cite_ids per the schema doc on event-ledger.ts:428.
|
|
168
160
|
// Multi-id citations share ONE parsed contract — propagate it across
|
|
169
|
-
// every id slot so downstream
|
|
170
|
-
//
|
|
171
|
-
//
|
|
161
|
+
// every id slot so the downstream consumer (`doctor.ts` per-cite
|
|
162
|
+
// cite-coverage walk) can look up `commitments[i]` for any valid
|
|
163
|
+
// `i < cite_ids.length` without falling into an undefined slot.
|
|
172
164
|
for (let i = 0; i < parsed.ids.length; i += 1) {
|
|
173
165
|
result.cite_commitments.push(parsed.commitment);
|
|
174
166
|
}
|