@fenglimg/fabric-cli 2.2.0-rc.3 → 2.2.0-rc.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/dist/{chunk-5LQIHYFC.js → chunk-27HK6H5Y.js} +10 -5
- package/dist/{chunk-F6ITRM7T.js → chunk-2KBCTMID.js} +29 -6
- package/dist/{chunk-XC5RUHLK.js → chunk-3IOLS5EK.js} +23 -38
- package/dist/{chunk-XHHCRDIR.js → chunk-CMDW3PYK.js} +105 -220
- package/dist/chunk-FEOPLBGA.js +150 -0
- package/dist/{chunk-XCBVSGCS.js → chunk-FNHDQTPC.js} +1 -10
- package/dist/{chunk-2CY4BMTH.js → chunk-HORSMSZL.js} +9 -5
- package/dist/{doctor-J4O3X54I.js → chunk-JTHWLUD3.js} +103 -51
- package/dist/{chunk-BO4XIZWZ.js → chunk-NLNH64A3.js} +5 -18
- package/dist/{chunk-H3FE6VIK.js → chunk-PTGQAZEW.js} +13 -3
- 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/{plan-context-hint-CHVZGOZ5.js → chunk-YM4XATJF.js} +29 -4
- package/dist/{config-VJMXCLXW.js → config-A3LTECAY.js} +4 -3
- package/dist/context-7NUKXDB6.js +117 -0
- package/dist/doctor-REZDNH4A.js +24 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +131 -21
- package/dist/info-7FKBTMVO.js +139 -0
- package/dist/install-v2-2COC3DO3.js +3277 -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-G75R4P4J.js +12 -0
- package/dist/{scope-explain-BWRWBCCP.js → scope-explain-HLJZ2M33.js} +3 -2
- package/dist/{status-PANEGKU2.js → status-4R3TM4FJ.js} +8 -5
- package/dist/store-HOCORVL3.js +563 -0
- package/dist/{sync-EA5HZMXM.js → sync-DT5UJMMR.js} +36 -13
- package/dist/{uninstall-F75MPKQC.js → uninstall-62F4LNKI.js} +62 -140
- package/dist/{whoami-66YKY5DZ.js → whoami-ITGEFWH4.js} +9 -7
- package/package.json +7 -5
- package/templates/hooks/cite-policy-evict.cjs +5 -5
- package/templates/hooks/configs/README.md +14 -27
- package/templates/hooks/configs/claude-code.json +1 -1
- package/templates/hooks/configs/codex-hooks.json +3 -3
- package/templates/hooks/fabric-hint.cjs +301 -161
- package/templates/hooks/knowledge-hint-broad.cjs +426 -207
- package/templates/hooks/knowledge-hint-narrow.cjs +56 -56
- package/templates/hooks/lib/banner-i18n.cjs +31 -0
- package/templates/hooks/lib/bindings-snapshot-reader.cjs +117 -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/lib/summary-fallback.cjs +82 -19
- package/templates/hooks/post-tooluse-mutation.cjs +112 -11
- package/templates/skills/fabric/SKILL.md +94 -0
- package/templates/skills/fabric-archive/SKILL.md +29 -26
- 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 +5 -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-5ZUMLCD5.js +0 -248
- package/dist/install-BULNDUIM.js +0 -2816
- package/dist/store-66NK2FTQ.js +0 -443
- package/templates/hooks/configs/cursor-hooks.json +0 -30
- package/templates/hooks/lib/cite-contract-reminder.cjs +0 -179
|
@@ -83,7 +83,7 @@ const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
|
|
|
83
83
|
// v2.0.0-rc.37 NEW-17: shared sidecar I/O for the plan-context-hint result
|
|
84
84
|
// cache (skips a redundant CLI cold-start spawn when the same path-set is
|
|
85
85
|
// re-edited within a session and the knowledge graph hasn't changed).
|
|
86
|
-
const {
|
|
86
|
+
const { readJsonStateAsync, writeJsonStateAsync } = require("./lib/state-store.cjs");
|
|
87
87
|
// W1-01 (ISS-011): the PreToolUse hook is the highest-frequency, most
|
|
88
88
|
// concurrency-exposed write surface in Fabric. Multi-window edits spawn
|
|
89
89
|
// concurrent hook processes that all append to the SAME non-session-scoped
|
|
@@ -93,7 +93,18 @@ const { readJsonState, writeJsonState } = require("./lib/state-store.cjs");
|
|
|
93
93
|
const { appendLockedLine } = require("./lib/injection-log.cjs");
|
|
94
94
|
// lifecycle-refactor W1-T2: client discriminator for the hook_surface_emitted
|
|
95
95
|
// event (schema requires the `client` enum). Mirrors the broad hook's import.
|
|
96
|
-
|
|
96
|
+
// v2.2 dual-sink (Goal A): + emitDualSink (PreToolUse two-channel emit).
|
|
97
|
+
const { detectClient, emitDualSink } = require("./lib/client-adapter.cjs");
|
|
98
|
+
// v2.2 dual-sink (Goal A / D4 + C5): human-output gate. On a narrow HIT the human
|
|
99
|
+
// systemMessage is gated by nudge_mode (a miss is already a silent early-return
|
|
100
|
+
// above); the AI additionalContext is emitted regardless (flow ⊥ observation).
|
|
101
|
+
// Optional require so an old install degrades to "always emit human".
|
|
102
|
+
let nudgePolicy = null;
|
|
103
|
+
try {
|
|
104
|
+
nudgePolicy = require("./lib/nudge-policy.cjs");
|
|
105
|
+
} catch {
|
|
106
|
+
// Lib missing (old install) — human sink always emits (legacy behavior).
|
|
107
|
+
}
|
|
97
108
|
// v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
|
|
98
109
|
// resolved-bindings snapshot. Store-aware hint surfaces the write-target store
|
|
99
110
|
// for the edited file WITHOUT re-resolving or walking store trees. Best-effort.
|
|
@@ -242,8 +253,8 @@ function readPayload(rawStdin) {
|
|
|
242
253
|
} catch (e) {
|
|
243
254
|
// v2.0.0-rc.29 REVIEW (codex LOW-1): apply BUG-L1's malformed-input
|
|
244
255
|
// diagnostic uniformly across hook scripts. fabric-hint.cjs got the stderr
|
|
245
|
-
// trace in TASK-008; without this matching write here, a broken Codex
|
|
246
|
-
//
|
|
256
|
+
// trace in TASK-008; without this matching write here, a broken Codex
|
|
257
|
+
// host payload silently kills the narrow hint with no operator
|
|
247
258
|
// signal at all. Best-effort: a failed stderr write must not throw upward
|
|
248
259
|
// (hook contract — never crash the host's edit pipeline).
|
|
249
260
|
try {
|
|
@@ -257,32 +268,27 @@ function readPayload(rawStdin) {
|
|
|
257
268
|
}
|
|
258
269
|
|
|
259
270
|
/**
|
|
260
|
-
* Extract the tool name from a hook payload.
|
|
261
|
-
*
|
|
271
|
+
* Extract the tool name from a hook payload. Both supported clients use the
|
|
272
|
+
* same shape:
|
|
262
273
|
* - Claude Code: { tool_name, tool_input: { ... } }
|
|
263
274
|
* - Codex CLI: { tool_name, tool_input: { ... } } (mirrors Claude)
|
|
264
|
-
* - Cursor: { tool, input: { ... } } (legacy variant)
|
|
265
275
|
* Returns null when no recognizable shape is present.
|
|
266
276
|
*/
|
|
267
277
|
function extractToolName(payload) {
|
|
268
278
|
if (!payload || typeof payload !== "object") return null;
|
|
269
279
|
if (typeof payload.tool_name === "string") return payload.tool_name;
|
|
270
|
-
if (typeof payload.tool === "string") return payload.tool;
|
|
271
280
|
return null;
|
|
272
281
|
}
|
|
273
282
|
|
|
274
283
|
/**
|
|
275
|
-
* Extract the tool_input object from a hook payload
|
|
276
|
-
*
|
|
284
|
+
* Extract the tool_input object from a hook payload (the `tool_input`
|
|
285
|
+
* convention shared by Claude Code and Codex CLI).
|
|
277
286
|
*/
|
|
278
287
|
function extractToolInput(payload) {
|
|
279
288
|
if (!payload || typeof payload !== "object") return null;
|
|
280
289
|
if (payload.tool_input && typeof payload.tool_input === "object") {
|
|
281
290
|
return payload.tool_input;
|
|
282
291
|
}
|
|
283
|
-
if (payload.input && typeof payload.input === "object") {
|
|
284
|
-
return payload.input;
|
|
285
|
-
}
|
|
286
292
|
return null;
|
|
287
293
|
}
|
|
288
294
|
|
|
@@ -292,8 +298,8 @@ function extractToolInput(payload) {
|
|
|
292
298
|
* - bulk variant: { file_paths: ["src/foo.ts", "src/bar.ts"] }
|
|
293
299
|
* - MultiEdit: { file_path: "...", edits: [{file_path?, ...}, ...] }
|
|
294
300
|
* (Claude Code's MultiEdit currently issues per-edit operations against
|
|
295
|
-
* a single `file_path`; older drafts
|
|
296
|
-
*
|
|
301
|
+
* a single `file_path`; older drafts carried per-edit `file_path`. We
|
|
302
|
+
* accept both to be defensive.)
|
|
297
303
|
*
|
|
298
304
|
* Returns a deduped array of strings — empty when no path is recognizable.
|
|
299
305
|
* Order: first occurrence wins (stable across re-renders of the same payload).
|
|
@@ -523,7 +529,7 @@ function appendHintSilenceCounter(projectRoot, now) {
|
|
|
523
529
|
/**
|
|
524
530
|
* Resolve the session id used to key the cache file. Priority:
|
|
525
531
|
* 1. payload.session_id (string, non-empty) — preferred; threads through
|
|
526
|
-
* from the client hook payload (Claude Code / Codex CLI
|
|
532
|
+
* from the client hook payload (Claude Code / Codex CLI).
|
|
527
533
|
* 2. process.env.FABRIC_SESSION_ID — environment fallback.
|
|
528
534
|
* 3. SYNTHETIC_SESSION_ID — a process-lifetime UUID, generated lazily so
|
|
529
535
|
* tests can stub it (see resetSyntheticSessionId).
|
|
@@ -865,9 +871,9 @@ function pathSetKey(paths) {
|
|
|
865
871
|
|
|
866
872
|
// Returns the cached cliPayload for `paths` iff the cache's meta token matches
|
|
867
873
|
// the current knowledge-graph freshness, else null (caller spawns the CLI).
|
|
868
|
-
function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
|
|
874
|
+
async function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
|
|
869
875
|
if (metaToken === null) return null;
|
|
870
|
-
const cache =
|
|
876
|
+
const cache = await readJsonStateAsync(
|
|
871
877
|
cwd,
|
|
872
878
|
narrowResultCacheFileName(sessionId),
|
|
873
879
|
(parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
|
|
@@ -880,10 +886,10 @@ function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
|
|
|
880
886
|
// Persist `cliPayload` under the path-set key. Resets the map when the meta
|
|
881
887
|
// token changed (stale graph) and caps the map size (FIFO-ish: drop oldest
|
|
882
888
|
// insertion-order keys). Best-effort — never throws.
|
|
883
|
-
function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
|
|
889
|
+
async function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
|
|
884
890
|
if (metaToken === null) return;
|
|
885
891
|
const fileName = narrowResultCacheFileName(sessionId);
|
|
886
|
-
const prior =
|
|
892
|
+
const prior = await readJsonStateAsync(
|
|
887
893
|
cwd,
|
|
888
894
|
fileName,
|
|
889
895
|
(parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
|
|
@@ -897,7 +903,7 @@ function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
|
|
|
897
903
|
delete results[stale];
|
|
898
904
|
}
|
|
899
905
|
}
|
|
900
|
-
|
|
906
|
+
await writeJsonStateAsync(cwd, fileName, { meta_token: metaToken, results });
|
|
901
907
|
}
|
|
902
908
|
|
|
903
909
|
// -----------------------------------------------------------------------------
|
|
@@ -1246,7 +1252,7 @@ function renderSummary(payload, maxLen) {
|
|
|
1246
1252
|
// Main — invoked as a CLI (require.main === module) and in-process by tests
|
|
1247
1253
|
// -----------------------------------------------------------------------------
|
|
1248
1254
|
|
|
1249
|
-
function main(env, stdio) {
|
|
1255
|
+
async function main(env, stdio) {
|
|
1250
1256
|
try {
|
|
1251
1257
|
const cwd = (env && env.cwd) || process.cwd();
|
|
1252
1258
|
const now = (env && env.now) || new Date();
|
|
@@ -1348,14 +1354,14 @@ function main(env, stdio) {
|
|
|
1348
1354
|
const useResultCache = !(env && env.skipResultCache === true);
|
|
1349
1355
|
const metaToken = useResultCache ? metaFreshnessToken(cwd) : null;
|
|
1350
1356
|
const cached = useResultCache
|
|
1351
|
-
? readNarrowResultCache(cwd, sessionId, paths, metaToken)
|
|
1357
|
+
? await readNarrowResultCache(cwd, sessionId, paths, metaToken)
|
|
1352
1358
|
: null;
|
|
1353
1359
|
if (cached !== null) {
|
|
1354
1360
|
cliPayload = cached;
|
|
1355
1361
|
} else {
|
|
1356
1362
|
cliPayload = invokePlanContextHint(cwd, paths);
|
|
1357
1363
|
if (useResultCache && cliPayload !== null && cliPayload !== undefined) {
|
|
1358
|
-
writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload);
|
|
1364
|
+
await writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload);
|
|
1359
1365
|
}
|
|
1360
1366
|
}
|
|
1361
1367
|
}
|
|
@@ -1522,9 +1528,27 @@ function main(env, stdio) {
|
|
|
1522
1528
|
}
|
|
1523
1529
|
}
|
|
1524
1530
|
|
|
1525
|
-
//
|
|
1526
|
-
|
|
1527
|
-
|
|
1531
|
+
// v2.2 dual-sink (Goal A / C5): a narrow HIT emits BOTH channels. The human
|
|
1532
|
+
// systemMessage is gated by nudge_mode (a MISS already returned silently far
|
|
1533
|
+
// above — narrow.length===0 / gate-skip / dedup-filter); the AI
|
|
1534
|
+
// additionalContext is emitted regardless (gated only by reminder_to_context),
|
|
1535
|
+
// preserving flow ⊥ observation (D5). emitDualSink shapes the protocol per
|
|
1536
|
+
// client (CC/Codex camelCase nested; unknown → stderr).
|
|
1537
|
+
const text = lines.join("\n");
|
|
1538
|
+
const humanGate =
|
|
1539
|
+
nudgePolicy !== null
|
|
1540
|
+
? nudgePolicy.resolveHumanSink(cwd, "pre_tool_use", { hit: true })
|
|
1541
|
+
: { emitHuman: true };
|
|
1542
|
+
const human = humanGate.emitHuman ? text : null;
|
|
1543
|
+
const ai = readReminderToContext(cwd) ? text : null;
|
|
1544
|
+
if (!(env && env.skipStdout === true)) {
|
|
1545
|
+
emitDualSink(
|
|
1546
|
+
{ human, ai },
|
|
1547
|
+
{ client: detectClient(), eventName: "PreToolUse", streams: { stdout: out, stderr: err } },
|
|
1548
|
+
);
|
|
1549
|
+
} else if (human !== null) {
|
|
1550
|
+
// skipStdout test seam: still surface the human breadcrumb to stderr.
|
|
1551
|
+
err.write(`${text}\n`);
|
|
1528
1552
|
}
|
|
1529
1553
|
|
|
1530
1554
|
// lifecycle-refactor W1-T2: hook_surface_emitted — record WHICH narrow-scoped
|
|
@@ -1569,33 +1593,10 @@ function main(env, stdio) {
|
|
|
1569
1593
|
// best-effort telemetry — never block the edit
|
|
1570
1594
|
}
|
|
1571
1595
|
|
|
1572
|
-
// v2.
|
|
1573
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
//
|
|
1576
|
-
// root cause: reminders never entered model context). PreToolUse hook
|
|
1577
|
-
// contract: stdout JSON with hookSpecificOutput.additionalContext is
|
|
1578
|
-
// injected into the model's context window; the hook DOES NOT block the
|
|
1579
|
-
// edit (additionalContext is informational, not a permissionDecision).
|
|
1580
|
-
// v2.0.0-rc.33 W4 review-fix (gemini High-1): CC-specific stdout envelope.
|
|
1581
|
-
// See knowledge-hint-broad.cjs companion for rationale — CLAUDE_PROJECT_DIR
|
|
1582
|
-
// is the CC presence signal; Codex CLI / Cursor don't set it.
|
|
1583
|
-
const _isClaudeCode =
|
|
1584
|
-
typeof process.env.CLAUDE_PROJECT_DIR === "string" &&
|
|
1585
|
-
process.env.CLAUDE_PROJECT_DIR.length > 0;
|
|
1586
|
-
if (!(env && env.skipStdout === true) && _isClaudeCode && readReminderToContext(cwd)) {
|
|
1587
|
-
try {
|
|
1588
|
-
const envelope = {
|
|
1589
|
-
hookSpecificOutput: {
|
|
1590
|
-
hookEventName: "PreToolUse",
|
|
1591
|
-
additionalContext: lines.join("\n"),
|
|
1592
|
-
},
|
|
1593
|
-
};
|
|
1594
|
-
out.write(`${JSON.stringify(envelope)}\n`);
|
|
1595
|
-
} catch {
|
|
1596
|
-
// Best-effort — stderr is the durable contract.
|
|
1597
|
-
}
|
|
1598
|
-
}
|
|
1596
|
+
// v2.2 dual-sink (Goal A): the legacy rc.33 W2-6 CC-only stdout envelope is
|
|
1597
|
+
// replaced by emitDualSink above (which carries BOTH the human systemMessage
|
|
1598
|
+
// and the AI additionalContext, shaped per client). reminder_to_context still
|
|
1599
|
+
// gates whether the AI sink is populated (see `ai` above).
|
|
1599
1600
|
|
|
1600
1601
|
// v2.0.0-rc.33 W2-5: record successful emit for cooldown gate.
|
|
1601
1602
|
if (cooldownHours > 0 && !(env && env.skipCooldownWrite === true)) {
|
|
@@ -1684,6 +1685,5 @@ if (require.main === module) {
|
|
|
1684
1685
|
main(
|
|
1685
1686
|
{ cwd: process.cwd(), now: new Date(), stdin: stdinRaw },
|
|
1686
1687
|
{ stderr: process.stderr },
|
|
1687
|
-
);
|
|
1688
|
-
process.exit(0);
|
|
1688
|
+
).finally(() => process.exit(0));
|
|
1689
1689
|
}
|
|
@@ -238,6 +238,37 @@ const STRINGS = {
|
|
|
238
238
|
"zh-CN-hybrid": () => " 是否调 `fabric doctor --lint` 看看知识库健康度?",
|
|
239
239
|
},
|
|
240
240
|
|
|
241
|
+
// ---- Stop hook: session-activity status (human trust anchor) -------------
|
|
242
|
+
// Observability grill (a): a no-signal Stop currently returns SILENT — the
|
|
243
|
+
// human only ever hears from Fabric when there is a nudge to act on, never a
|
|
244
|
+
// "here is what I did" status, which reads as "Fabric does nothing". This line
|
|
245
|
+
// is the trust anchor: session-scoped counts from events.jsonl (edits +
|
|
246
|
+
// knowledge pulls by the AI + pending backlog). Cadence is gated by nudge_mode
|
|
247
|
+
// (silent=never, normal=once/session, verbose=every turn) at the call site.
|
|
248
|
+
// params: { edits, consumed, pending } — all numbers.
|
|
249
|
+
statusLine: {
|
|
250
|
+
"zh-CN": (p) =>
|
|
251
|
+
`📋 Fabric 本会话 · 改 ${p.edits} 文件 · AI 取用知识 ${p.consumed} 次 · 待审 ${p.pending} 条`,
|
|
252
|
+
en: (p) =>
|
|
253
|
+
`📋 Fabric this session · ${p.edits} files edited · ${p.consumed} KB pulls by AI · ${p.pending} pending`,
|
|
254
|
+
"zh-CN-hybrid": (p) =>
|
|
255
|
+
`📋 Fabric 本会话 · 改 ${p.edits} 文件 · AI 取用知识 ${p.consumed} 次 · 待审 ${p.pending} 条`,
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
// ---- Stop hook: nudge_mode tier guidance (discoverability) ---------------
|
|
259
|
+
// Observability grill (Q4): users did not know the human-channel volume knob
|
|
260
|
+
// (nudge_mode) exists, so they assumed the hooks never surface to humans. This
|
|
261
|
+
// line names the current tier and the levers. params: { mode } — current
|
|
262
|
+
// nudge_mode. Protected token: nudge_mode + the config path verbatim.
|
|
263
|
+
statusTier: {
|
|
264
|
+
"zh-CN": (p) =>
|
|
265
|
+
` 音量 ${p.mode}:verbose=每步可见 / silent=静音(.fabric/fabric-config.json nudge_mode)`,
|
|
266
|
+
en: (p) =>
|
|
267
|
+
` volume ${p.mode}: verbose=show every step / silent=mute (.fabric/fabric-config.json nudge_mode)`,
|
|
268
|
+
"zh-CN-hybrid": (p) =>
|
|
269
|
+
` 音量 ${p.mode}:verbose=每步可见 / silent=静音(.fabric/fabric-config.json nudge_mode)`,
|
|
270
|
+
},
|
|
271
|
+
|
|
241
272
|
// ---- Broad hook: import recommendation ------------------------------------
|
|
242
273
|
// Source (zh-CN): knowledge-hint-broad.cjs:262
|
|
243
274
|
// " 📋 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,102 @@ 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
|
+
// Backward-compatible fallback: snapshot predates knowledge_store_dirs.
|
|
152
|
+
const stats = snapshot.knowledge_stats;
|
|
153
|
+
if (stats && typeof stats === "object") {
|
|
154
|
+
return {
|
|
155
|
+
pendingCount: Number.isFinite(stats.pending_count) ? Math.floor(stats.pending_count) : 0,
|
|
156
|
+
canonicalCount: Number.isFinite(stats.canonical_count) ? Math.floor(stats.canonical_count) : 0,
|
|
157
|
+
oldestPendingMtimeMs:
|
|
158
|
+
Number.isFinite(stats.oldest_pending_mtime_ms) && stats.oldest_pending_mtime_ms > 0
|
|
159
|
+
? stats.oldest_pending_mtime_ms
|
|
160
|
+
: null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
57
166
|
// Render a compact, per-store label line for a SessionStart / Stop hook from a
|
|
58
167
|
// snapshot. Empty string when there is nothing to show (degrade silently). The
|
|
59
168
|
// label is provenance only — it never re-resolves; it just echoes the read-set
|
|
@@ -77,5 +186,6 @@ module.exports = {
|
|
|
77
186
|
resolveGlobalRoot,
|
|
78
187
|
bindingsSnapshotPath,
|
|
79
188
|
readBindingsSnapshot,
|
|
189
|
+
liveKnowledgeStats,
|
|
80
190
|
formatStoreLabels,
|
|
81
191
|
};
|
|
@@ -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
|
}
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* v2.0.0-rc.37 NEW-30: shared client-protocol adapter for hook scripts.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
4
|
+
* The two host clients (Claude Code / Codex CLI) differ in how a
|
|
5
5
|
* hook surfaces context back to the model:
|
|
6
6
|
* - Claude Code reads a stdout JSON envelope
|
|
7
7
|
* ({ hookSpecificOutput: { hookEventName, additionalContext } }).
|
|
8
|
-
* - Codex CLI
|
|
8
|
+
* - Codex CLI reads plain stderr text.
|
|
9
9
|
* Each hook had its own copy of the detect-client + read-stdin + pick-channel
|
|
10
10
|
* logic (fabric-hint.detectClient, cite-policy-evict.isClaudeCode + readStdinJson,
|
|
11
11
|
* knowledge-hint-broad inline CLAUDE_PROJECT_DIR check). This module is the
|
|
12
12
|
* single canonical implementation so the protocol choice lives in one place.
|
|
13
13
|
*
|
|
14
14
|
* Provides:
|
|
15
|
-
* - detectClient(dirnameHint?) → 'cc' | 'codex' |
|
|
15
|
+
* - detectClient(dirnameHint?) → 'cc' | 'codex' | undefined
|
|
16
16
|
* 3-tier: FABRIC_HINT_CLIENT env override → CLAUDE_PROJECT_DIR (cc) →
|
|
17
|
-
* __dirname path heuristic (.claude / .codex
|
|
17
|
+
* __dirname path heuristic (.claude / .codex). dirnameHint
|
|
18
18
|
* defaults to this lib's own dir (which still lives under the client
|
|
19
19
|
* dir, e.g. .claude/hooks/lib), so the heuristic stays accurate.
|
|
20
20
|
* - isClaudeCode() → boolean (CLAUDE_PROJECT_DIR present)
|
|
21
21
|
* - readStdinJson({ timeoutMs }) → Promise<object | null>
|
|
22
22
|
* Async stdin JSON reader; null on parse error / closed stdin / timeout.
|
|
23
23
|
* - emitContext(text, { client, eventName, streams, forceStderr }) → void
|
|
24
|
-
* Standardised output: Claude Code → stdout JSON envelope; Codex
|
|
24
|
+
* Standardised output: Claude Code → stdout JSON envelope; Codex
|
|
25
25
|
* → plain stderr. forceStderr pins stderr even on Claude Code (used for
|
|
26
26
|
* SessionStart one-shot reminders). Best-effort — never throws.
|
|
27
27
|
*
|
|
@@ -40,7 +40,7 @@ function detectClient(dirnameHint) {
|
|
|
40
40
|
const envClient = process.env.FABRIC_HINT_CLIENT;
|
|
41
41
|
if (typeof envClient === "string" && envClient.length > 0) {
|
|
42
42
|
const normalised = envClient.trim().toLowerCase();
|
|
43
|
-
if (normalised === "cc" || normalised === "codex"
|
|
43
|
+
if (normalised === "cc" || normalised === "codex") {
|
|
44
44
|
return normalised;
|
|
45
45
|
}
|
|
46
46
|
}
|
|
@@ -51,7 +51,6 @@ function detectClient(dirnameHint) {
|
|
|
51
51
|
try {
|
|
52
52
|
if (dir.includes(".claude/") || dir.includes(".claude\\")) return "cc";
|
|
53
53
|
if (dir.includes(".codex/") || dir.includes(".codex\\")) return "codex";
|
|
54
|
-
if (dir.includes(".cursor/") || dir.includes(".cursor\\")) return "cursor";
|
|
55
54
|
} catch {
|
|
56
55
|
// fall through
|
|
57
56
|
}
|
|
@@ -98,9 +97,69 @@ function emitContext(text, opts) {
|
|
|
98
97
|
}
|
|
99
98
|
}
|
|
100
99
|
|
|
100
|
+
// v2.2 dual-sink (Goal A / D7): two-channel emit. Unlike emitContext (which
|
|
101
|
+
// picks ONE channel), emitDualSink surfaces a knowledge breadcrumb to BOTH the
|
|
102
|
+
// human and the AI in one render, split into two fields, with the protocol
|
|
103
|
+
// shaped per client:
|
|
104
|
+
//
|
|
105
|
+
// cc / codex (symmetric, D7): a single stdout JSON envelope carrying
|
|
106
|
+
// { systemMessage: <human>, // the human sink
|
|
107
|
+
// hookSpecificOutput: { hookEventName, additionalContext: <ai> } } // AI sink
|
|
108
|
+
// camelCase + nested. `systemMessage` is the universal human-facing field
|
|
109
|
+
// (verified against official hook docs in the mode④ design session); it is
|
|
110
|
+
// what fixes the "stderr human channel is dead on CC" gap — CC suppresses
|
|
111
|
+
// hook stderr at exit 0, so the human never saw the old breadcrumb.
|
|
112
|
+
//
|
|
113
|
+
// unknown client (detection failed, not CC): fall back to a plain stderr
|
|
114
|
+
// breadcrumb (human preferred, else ai) — no known JSON contract to target.
|
|
115
|
+
//
|
|
116
|
+
// Either field may be null/empty: pass { human, ai } and only the present
|
|
117
|
+
// channels are written (e.g. a PreToolUse miss passes human:null → AI-only;
|
|
118
|
+
// nudge_mode silent passes human:null too). The AI field is ALWAYS the caller's
|
|
119
|
+
// to decide independently — this fn never derives one channel from the other,
|
|
120
|
+
// preserving the flow ⊥ observation invariant (D5).
|
|
121
|
+
//
|
|
122
|
+
// Never-throw contract (KT-DEC-0007): every path degrades silently.
|
|
123
|
+
function emitDualSink(payload, opts) {
|
|
124
|
+
const { human = null, ai = null } = payload || {};
|
|
125
|
+
const { client, eventName = "SessionStart", streams = {} } = opts || {};
|
|
126
|
+
const stdout = streams.stdout || process.stdout;
|
|
127
|
+
const stderr = streams.stderr || process.stderr;
|
|
128
|
+
const hasHuman = typeof human === "string" && human.length > 0;
|
|
129
|
+
const hasAi = typeof ai === "string" && ai.length > 0;
|
|
130
|
+
const resolved = client || detectClient();
|
|
131
|
+
try {
|
|
132
|
+
const useEnvelope =
|
|
133
|
+
resolved === "cc" ||
|
|
134
|
+
resolved === "codex" ||
|
|
135
|
+
(resolved === undefined && isClaudeCode());
|
|
136
|
+
if (useEnvelope) {
|
|
137
|
+
const envelope = {};
|
|
138
|
+
if (hasHuman) envelope.systemMessage = human;
|
|
139
|
+
if (hasAi) {
|
|
140
|
+
envelope.hookSpecificOutput = {
|
|
141
|
+
hookEventName: eventName,
|
|
142
|
+
additionalContext: ai,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (Object.keys(envelope).length > 0) {
|
|
146
|
+
stdout.write(`${JSON.stringify(envelope)}\n`);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// Unknown client: no JSON contract — surface the human breadcrumb (or ai)
|
|
151
|
+
// on stderr as a last resort so something is visible.
|
|
152
|
+
const fallback = hasHuman ? human : hasAi ? ai : null;
|
|
153
|
+
if (fallback !== null) stderr.write(`${fallback}\n`);
|
|
154
|
+
} catch {
|
|
155
|
+
// best-effort — never throw
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
101
159
|
module.exports = {
|
|
102
160
|
isClaudeCode,
|
|
103
161
|
detectClient,
|
|
104
162
|
readStdinJson,
|
|
105
163
|
emitContext,
|
|
164
|
+
emitDualSink,
|
|
106
165
|
};
|