@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.
Files changed (88) hide show
  1. package/README.md +8 -5
  2. package/dist/chunk-27HK6H5Y.js +69 -0
  3. package/dist/{chunk-BATF4PEJ.js → chunk-2KBCTMID.js} +31 -8
  4. package/dist/chunk-3D7B2UAZ.js +149 -0
  5. package/dist/{chunk-MF3OTILQ.js → chunk-3IOLS5EK.js} +48 -42
  6. package/dist/{plan-context-hint-FC6P3WFE.js → chunk-722JU5BP.js} +52 -12
  7. package/dist/{chunk-F46ORPOA.js → chunk-7ZDXBOOU.js} +271 -166
  8. package/dist/{doctor-QVNPHLJK.js → chunk-E7HJUU34.js} +248 -72
  9. package/dist/chunk-EOT63RDH.js +36 -0
  10. package/dist/chunk-FNHDQTPC.js +16 -0
  11. package/dist/chunk-HORSMSZL.js +26 -0
  12. package/dist/chunk-NLNH64A3.js +43 -0
  13. package/dist/{chunk-WU6GAPKH.js → chunk-PTGQAZEW.js} +12 -4
  14. package/dist/chunk-QFIVFZRH.js +13 -0
  15. package/dist/chunk-QPAW6IYT.js +387 -0
  16. package/dist/{chunk-COI5VDFU.js → chunk-WA3DYGSY.js} +1 -2
  17. package/dist/{config-XJIPZNUP.js → config-A3LTECAY.js} +4 -3
  18. package/dist/context-UJCGYOT6.js +117 -0
  19. package/dist/doctor-MDTZWKBK.js +24 -0
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +167 -16
  22. package/dist/info-7FKBTMVO.js +139 -0
  23. package/dist/install-v2-RINEA24K.js +3279 -0
  24. package/dist/{metrics-ACEQFPDU.js → metrics-HMFH4YHK.js} +22 -9
  25. package/dist/{onboard-coverage-MFCAEBDO.js → onboard-coverage-XSG77LL3.js} +48 -27
  26. package/dist/plan-context-hint-5TNGH3R4.js +12 -0
  27. package/dist/scope-explain-HLJZ2M33.js +48 -0
  28. package/dist/status-4R3TM4FJ.js +37 -0
  29. package/dist/store-HOCORVL3.js +563 -0
  30. package/dist/sync-DT5UJMMR.js +418 -0
  31. package/dist/{uninstall-TAXSUSKH.js → uninstall-IFN2KYBK.js} +128 -140
  32. package/dist/whoami-ITGEFWH4.js +49 -0
  33. package/package.json +7 -5
  34. package/templates/hooks/cite-policy-evict.cjs +412 -160
  35. package/templates/hooks/configs/README.md +14 -27
  36. package/templates/hooks/configs/claude-code.json +17 -2
  37. package/templates/hooks/configs/codex-hooks.json +15 -3
  38. package/templates/hooks/fabric-hint.cjs +573 -180
  39. package/templates/hooks/knowledge-hint-broad.cjs +648 -190
  40. package/templates/hooks/knowledge-hint-narrow.cjs +123 -77
  41. package/templates/hooks/lib/banner-i18n.cjs +31 -0
  42. package/templates/hooks/lib/bindings-snapshot-reader.cjs +118 -7
  43. package/templates/hooks/lib/cite-line-parser.cjs +12 -20
  44. package/templates/hooks/lib/client-adapter.cjs +66 -7
  45. package/templates/hooks/lib/injection-log.cjs +91 -0
  46. package/templates/hooks/lib/nudge-policy.cjs +117 -0
  47. package/templates/hooks/lib/state-store.cjs +90 -11
  48. package/templates/hooks/post-tooluse-mutation.cjs +386 -0
  49. package/templates/hooks/session-end-marker.cjs +140 -0
  50. package/templates/skills/fabric/SKILL.md +100 -0
  51. package/templates/skills/fabric-archive/SKILL.md +35 -24
  52. package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
  53. package/templates/skills/fabric-archive/ref/i18n-policy.md +2 -3
  54. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +2 -3
  55. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +1 -1
  56. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +1 -1
  57. package/templates/skills/fabric-archive/ref/phase-3-6-related-edges.md +18 -0
  58. package/templates/skills/fabric-archive/ref/phase-3-7-semantic-scope.md +47 -0
  59. package/templates/skills/fabric-audit/SKILL.md +63 -0
  60. package/templates/skills/fabric-connect/SKILL.md +48 -0
  61. package/templates/skills/fabric-import/SKILL.md +7 -7
  62. package/templates/skills/fabric-import/ref/i18n-policy.md +2 -3
  63. package/templates/skills/fabric-import/ref/state-recovery.md +1 -2
  64. package/templates/skills/fabric-review/SKILL.md +16 -5
  65. package/templates/skills/fabric-review/ref/cite-contract.md +56 -0
  66. package/templates/skills/fabric-review/ref/i18n-policy.md +2 -3
  67. package/templates/skills/fabric-review/ref/output-contract.md +1 -1
  68. package/templates/skills/fabric-review/ref/per-mode-flows.md +2 -2
  69. package/templates/skills/fabric-review/ref/worked-examples.md +1 -1
  70. package/templates/skills/fabric-store/SKILL.md +44 -0
  71. package/templates/skills/fabric-sync/SKILL.md +1 -1
  72. package/templates/skills/lib/shared-policy.md +2 -2
  73. package/dist/chunk-HFQVXY6P.js +0 -86
  74. package/dist/chunk-L4Q55UC4.js +0 -52
  75. package/dist/chunk-LFIKMVY7.js +0 -27
  76. package/dist/chunk-PWLW3B57.js +0 -18
  77. package/dist/chunk-RYAFBNES.js +0 -33
  78. package/dist/chunk-T5RPGCCM.js +0 -40
  79. package/dist/chunk-WWNXR34K.js +0 -49
  80. package/dist/install-2HDO5FTQ.js +0 -2683
  81. package/dist/scope-explain-2F2R5URO.js +0 -33
  82. package/dist/status-GLQWLWH6.js +0 -23
  83. package/dist/store-XTSE5TY6.js +0 -105
  84. package/dist/sync-BJCWDPNC.js +0 -245
  85. package/dist/whoami-B6AEMSEV.js +0 -31
  86. package/templates/hooks/configs/cursor-hooks.json +0 -18
  87. package/templates/hooks/lib/cite-contract-reminder.cjs +0 -179
  88. package/templates/hooks/lib/summary-fallback.cjs +0 -210
@@ -66,7 +66,6 @@
66
66
  const { spawnSync } = require("node:child_process");
67
67
  const { createHash, randomUUID } = require("node:crypto");
68
68
  const {
69
- appendFileSync,
70
69
  existsSync,
71
70
  mkdirSync,
72
71
  readFileSync,
@@ -76,15 +75,34 @@ const {
76
75
  } = require("node:fs");
77
76
  const { dirname, join } = require("node:path");
78
77
 
79
- // rc.35 TASK-06 (P0-10.b): summary-fallback. Substitutes opaque entries
80
- // (where description.summary === stable_id) with a snippet read from the
81
- // entry's .md `## Summary` section. Caches results in
82
- // `.fabric/.cache/summary-fallback.json` keyed by revision_hash.
83
- 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.
84
81
  // v2.0.0-rc.37 NEW-17: shared sidecar I/O for the plan-context-hint result
85
82
  // cache (skips a redundant CLI cold-start spawn when the same path-set is
86
83
  // re-edited within a session and the knowledge graph hasn't changed).
87
- const { readJsonState, writeJsonState } = require("./lib/state-store.cjs");
84
+ const { readJsonStateAsync, writeJsonStateAsync } = require("./lib/state-store.cjs");
85
+ // W1-01 (ISS-011): the PreToolUse hook is the highest-frequency, most
86
+ // concurrency-exposed write surface in Fabric. Multi-window edits spawn
87
+ // concurrent hook processes that all append to the SAME non-session-scoped
88
+ // ledger/counter files; a bare appendFileSync can interleave a partial write
89
+ // and corrupt a line. Route every shared-file append through the advisory-lock
90
+ // primitive (drop-on-contention, best-effort — matches injection-log).
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
+ }
88
106
  // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
89
107
  // resolved-bindings snapshot. Store-aware hint surfaces the write-target store
90
108
  // for the edited file WITHOUT re-resolving or walking store trees. Best-effort.
@@ -233,8 +251,8 @@ function readPayload(rawStdin) {
233
251
  } catch (e) {
234
252
  // v2.0.0-rc.29 REVIEW (codex LOW-1): apply BUG-L1's malformed-input
235
253
  // diagnostic uniformly across hook scripts. fabric-hint.cjs got the stderr
236
- // trace in TASK-008; without this matching write here, a broken Codex /
237
- // Cursor host payload silently kills the narrow hint with no operator
254
+ // trace in TASK-008; without this matching write here, a broken Codex
255
+ // host payload silently kills the narrow hint with no operator
238
256
  // signal at all. Best-effort: a failed stderr write must not throw upward
239
257
  // (hook contract — never crash the host's edit pipeline).
240
258
  try {
@@ -248,32 +266,27 @@ function readPayload(rawStdin) {
248
266
  }
249
267
 
250
268
  /**
251
- * Extract the tool name from a hook payload. Clients differ in casing /
252
- * field placement; we probe the conventional shapes:
269
+ * Extract the tool name from a hook payload. Both supported clients use the
270
+ * same shape:
253
271
  * - Claude Code: { tool_name, tool_input: { ... } }
254
272
  * - Codex CLI: { tool_name, tool_input: { ... } } (mirrors Claude)
255
- * - Cursor: { tool, input: { ... } } (legacy variant)
256
273
  * Returns null when no recognizable shape is present.
257
274
  */
258
275
  function extractToolName(payload) {
259
276
  if (!payload || typeof payload !== "object") return null;
260
277
  if (typeof payload.tool_name === "string") return payload.tool_name;
261
- if (typeof payload.tool === "string") return payload.tool;
262
278
  return null;
263
279
  }
264
280
 
265
281
  /**
266
- * Extract the tool_input object from a hook payload, accepting both the
267
- * `tool_input` (Claude/Codex) and `input` (Cursor) conventions.
282
+ * Extract the tool_input object from a hook payload (the `tool_input`
283
+ * convention shared by Claude Code and Codex CLI).
268
284
  */
269
285
  function extractToolInput(payload) {
270
286
  if (!payload || typeof payload !== "object") return null;
271
287
  if (payload.tool_input && typeof payload.tool_input === "object") {
272
288
  return payload.tool_input;
273
289
  }
274
- if (payload.input && typeof payload.input === "object") {
275
- return payload.input;
276
- }
277
290
  return null;
278
291
  }
279
292
 
@@ -283,8 +296,8 @@ function extractToolInput(payload) {
283
296
  * - bulk variant: { file_paths: ["src/foo.ts", "src/bar.ts"] }
284
297
  * - MultiEdit: { file_path: "...", edits: [{file_path?, ...}, ...] }
285
298
  * (Claude Code's MultiEdit currently issues per-edit operations against
286
- * a single `file_path`; older drafts and Cursor's variant carried
287
- * per-edit `file_path`. We accept both to be defensive.)
299
+ * a single `file_path`; older drafts carried per-edit `file_path`. We
300
+ * accept both to be defensive.)
288
301
  *
289
302
  * Returns a deduped array of strings — empty when no path is recognizable.
290
303
  * Order: first occurrence wins (stable across re-renders of the same payload).
@@ -427,7 +440,7 @@ function appendEditIntentToLedger(projectRoot, now, paths, toolName, sessionId)
427
440
  window_ms: 0,
428
441
  }))
429
442
  .join("\n") + "\n";
430
- appendFileSync(join(fabricDir, EVENTS_LEDGER_FILE), lines, "utf8");
443
+ appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), lines);
431
444
  } catch {
432
445
  // Silent — events ledger failure must never block the edit.
433
446
  }
@@ -471,7 +484,7 @@ function appendEditCounter(projectRoot, now, paths) {
471
484
  .filter((p) => typeof p === "string" && p.length > 0)
472
485
  : [];
473
486
  const line = JSON.stringify({ ts: iso, paths: pathList });
474
- appendFileSync(file, `${line}\n`, "utf8");
487
+ appendLockedLine(file, `${line}\n`);
475
488
  } catch {
476
489
  // Silent — sidecar failure must never block the edit.
477
490
  }
@@ -501,7 +514,7 @@ function appendHintSilenceCounter(projectRoot, now) {
501
514
  mkdirSync(dir, { recursive: true });
502
515
  }
503
516
  const iso = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
504
- appendFileSync(file, `${iso}\n`, "utf8");
517
+ appendLockedLine(file, `${iso}\n`);
505
518
  } catch {
506
519
  // Silent — sidecar failure must never block the edit.
507
520
  }
@@ -514,7 +527,7 @@ function appendHintSilenceCounter(projectRoot, now) {
514
527
  /**
515
528
  * Resolve the session id used to key the cache file. Priority:
516
529
  * 1. payload.session_id (string, non-empty) — preferred; threads through
517
- * from the client hook payload (Claude Code / Codex CLI / Cursor).
530
+ * from the client hook payload (Claude Code / Codex CLI).
518
531
  * 2. process.env.FABRIC_SESSION_ID — environment fallback.
519
532
  * 3. SYNTHETIC_SESSION_ID — a process-lifetime UUID, generated lazily so
520
533
  * tests can stub it (see resetSyntheticSessionId).
@@ -856,9 +869,9 @@ function pathSetKey(paths) {
856
869
 
857
870
  // Returns the cached cliPayload for `paths` iff the cache's meta token matches
858
871
  // the current knowledge-graph freshness, else null (caller spawns the CLI).
859
- function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
872
+ async function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
860
873
  if (metaToken === null) return null;
861
- const cache = readJsonState(
874
+ const cache = await readJsonStateAsync(
862
875
  cwd,
863
876
  narrowResultCacheFileName(sessionId),
864
877
  (parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
@@ -871,10 +884,10 @@ function readNarrowResultCache(cwd, sessionId, paths, metaToken) {
871
884
  // Persist `cliPayload` under the path-set key. Resets the map when the meta
872
885
  // token changed (stale graph) and caps the map size (FIFO-ish: drop oldest
873
886
  // insertion-order keys). Best-effort — never throws.
874
- function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
887
+ async function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
875
888
  if (metaToken === null) return;
876
889
  const fileName = narrowResultCacheFileName(sessionId);
877
- const prior = readJsonState(
890
+ const prior = await readJsonStateAsync(
878
891
  cwd,
879
892
  fileName,
880
893
  (parsed) => parsed && typeof parsed === "object" && parsed.results && typeof parsed.results === "object",
@@ -888,7 +901,7 @@ function writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload) {
888
901
  delete results[stale];
889
902
  }
890
903
  }
891
- writeJsonState(cwd, fileName, { meta_token: metaToken, results });
904
+ await writeJsonStateAsync(cwd, fileName, { meta_token: metaToken, results });
892
905
  }
893
906
 
894
907
  // -----------------------------------------------------------------------------
@@ -1163,7 +1176,15 @@ function formatEntryLine(entry, maxLen) {
1163
1176
  const maturity = entry.maturity || "unknown";
1164
1177
  const summary = truncateSummary(entry.summary, maxLen);
1165
1178
  const tail = summary.length > 0 ? ` ${summary}` : "";
1166
- return ` [${id}] (${type}/${maturity})${tail}`;
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}`;
1167
1188
  }
1168
1189
 
1169
1190
  function readSummaryMaxLen(projectRoot) {
@@ -1229,7 +1250,7 @@ function renderSummary(payload, maxLen) {
1229
1250
  // Main — invoked as a CLI (require.main === module) and in-process by tests
1230
1251
  // -----------------------------------------------------------------------------
1231
1252
 
1232
- function main(env, stdio) {
1253
+ async function main(env, stdio) {
1233
1254
  try {
1234
1255
  const cwd = (env && env.cwd) || process.cwd();
1235
1256
  const now = (env && env.now) || new Date();
@@ -1331,14 +1352,14 @@ function main(env, stdio) {
1331
1352
  const useResultCache = !(env && env.skipResultCache === true);
1332
1353
  const metaToken = useResultCache ? metaFreshnessToken(cwd) : null;
1333
1354
  const cached = useResultCache
1334
- ? readNarrowResultCache(cwd, sessionId, paths, metaToken)
1355
+ ? await readNarrowResultCache(cwd, sessionId, paths, metaToken)
1335
1356
  : null;
1336
1357
  if (cached !== null) {
1337
1358
  cliPayload = cached;
1338
1359
  } else {
1339
1360
  cliPayload = invokePlanContextHint(cwd, paths);
1340
1361
  if (useResultCache && cliPayload !== null && cliPayload !== undefined) {
1341
- writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload);
1362
+ await writeNarrowResultCache(cwd, sessionId, paths, metaToken, cliPayload);
1342
1363
  }
1343
1364
  }
1344
1365
  }
@@ -1469,21 +1490,10 @@ function main(env, stdio) {
1469
1490
  }
1470
1491
 
1471
1492
  const summaryMaxLen = readSummaryMaxLen(cwd);
1472
- // rc.35 TASK-06 (P0-10.b): substitute opaque summaries before render.
1473
- // Same lib used by the broad hook — opaque entries seen from both call
1474
- // sites share a single .fabric/.cache/summary-fallback.json file.
1475
- // Best-effort any failure leaves the original opaque summary intact.
1476
- let resolvedEntries = dedupDecision.filtered;
1477
- try {
1478
- resolvedEntries = resolveOpaqueSummaries(
1479
- dedupDecision.filtered,
1480
- cwd,
1481
- currentRevisionHash,
1482
- );
1483
- } catch {
1484
- // resolveOpaqueSummaries swallows its own errors; defensive catch.
1485
- }
1486
- 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);
1487
1497
  if (lines.length === 0) return;
1488
1498
 
1489
1499
  // v2.1.0-rc.1 P4 (F4/S63): store-aware hint — append the write-target store
@@ -1505,39 +1515,76 @@ function main(env, stdio) {
1505
1515
  }
1506
1516
  }
1507
1517
 
1508
- // Stderr: human-facing breadcrumb + legacy contract.
1509
- for (const line of lines) {
1510
- err.write(`${line}\n`);
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`);
1511
1539
  }
1512
1540
 
1513
- // v2.0.0-rc.33 W2-6 (P0-7): stdout JSON envelope. When
1514
- // hint_reminder_to_context is true (default), serialize the same banner
1515
- // body as Claude Code's PreToolUse hookSpecificOutput shape so the model
1516
- // receives the reminder IN-CONTEXT (rc.32 baseline cite-coverage 3.1%
1517
- // root cause: reminders never entered model context). PreToolUse hook
1518
- // contract: stdout JSON with hookSpecificOutput.additionalContext is
1519
- // injected into the model's context window; the hook DOES NOT block the
1520
- // edit (additionalContext is informational, not a permissionDecision).
1521
- // v2.0.0-rc.33 W4 review-fix (gemini High-1): CC-specific stdout envelope.
1522
- // See knowledge-hint-broad.cjs companion for rationale — CLAUDE_PROJECT_DIR
1523
- // is the CC presence signal; Codex CLI / Cursor don't set it.
1524
- const _isClaudeCode =
1525
- typeof process.env.CLAUDE_PROJECT_DIR === "string" &&
1526
- process.env.CLAUDE_PROJECT_DIR.length > 0;
1527
- if (!(env && env.skipStdout === true) && _isClaudeCode && readReminderToContext(cwd)) {
1528
- try {
1529
- const envelope = {
1530
- hookSpecificOutput: {
1531
- hookEventName: "PreToolUse",
1532
- additionalContext: lines.join("\n"),
1533
- },
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",
1534
1576
  };
1535
- out.write(`${JSON.stringify(envelope)}\n`);
1536
- } catch {
1537
- // Best-effort — stderr is the durable contract.
1577
+ appendLockedLine(join(fabricDir, EVENTS_LEDGER_FILE), JSON.stringify(surfaceEvent) + "\n");
1538
1578
  }
1579
+ } catch {
1580
+ // best-effort telemetry — never block the edit
1539
1581
  }
1540
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
+
1541
1588
  // v2.0.0-rc.33 W2-5: record successful emit for cooldown gate.
1542
1589
  if (cooldownHours > 0 && !(env && env.skipCooldownWrite === true)) {
1543
1590
  writeNarrowLastEmit(cwd, nowMs);
@@ -1625,6 +1672,5 @@ if (require.main === module) {
1625
1672
  main(
1626
1673
  { cwd: process.cwd(), now: new Date(), stdin: stdinRaw },
1627
1674
  { stderr: process.stderr },
1628
- );
1629
- process.exit(0);
1675
+ ).finally(() => process.exit(0));
1630
1676
  }
@@ -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/<project_id>_resolved.json` (written by P3
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(projectId, globalRoot) {
35
+ function bindingsSnapshotPath(bindingId, globalRoot) {
23
36
  return join(
24
37
  globalRoot || resolveGlobalRoot(),
25
38
  "state",
26
39
  "bindings",
27
- projectId + "_resolved.json",
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(projectId, globalRoot) {
34
- if (typeof projectId !== "string" || projectId.length === 0) {
46
+ function readBindingsSnapshot(bindingId, globalRoot) {
47
+ if (typeof bindingId !== "string" || bindingId.length === 0) {
35
48
  return null;
36
49
  }
37
- const path = bindingsSnapshotPath(projectId, globalRoot);
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/cursor with no install pipeline change.
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 (rc.37 NEW-1 2-state vocab).
22
- // v2.1.0-rc.1 (ADJ-P4-1, full remap): legacy rc≤36 tags (planned / recalled
23
- // / chained-from) are REMAPPED to `applied` here accepted as input but no
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
- // v2.1.0-rc.1 (ADJ-P4-1, full remap): legacy rc≤36 tags collapse to `applied`.
56
- // Mirrors LEGACY_CITE_TAG_REMAP / normalizeCiteTag in the TS source — accepted
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 `chained-from KT-DEC-0001` or
67
- // `dismissed:scope-mismatch`; head token (whitespace/colon-bounded) wins.
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 LEGACY_CITE_TAG_REMAP[head] || "none";
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 consumers (`doctor.ts` per-cite walk +
170
- // `cite-contract-reminder.cjs`) can look up `commitments[i]` for any
171
- // valid `i < cite_ids.length` without falling into an undefined slot.
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
  }