@fenglimg/fabric-cli 2.2.0 → 2.3.0-rc.1

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 (74) hide show
  1. package/README.md +2 -2
  2. package/dist/audit-PURSJJFH.js +734 -0
  3. package/dist/{chunk-QPAW6IYT.js → chunk-7V4XMLQ2.js} +3 -3
  4. package/dist/{chunk-7ZDXBOOU.js → chunk-ACSMNX3V.js} +44 -128
  5. package/dist/{chunk-PTGQAZEW.js → chunk-GGDVZCD6.js} +2 -4
  6. package/dist/{chunk-EOT63RDH.js → chunk-I5F5BHWI.js} +9 -0
  7. package/dist/chunk-PP7QVRXH.js +565 -0
  8. package/dist/chunk-SL77FXX7.js +54 -0
  9. package/dist/{chunk-3D7B2UAZ.js → chunk-VQKXTMWH.js} +44 -4
  10. package/dist/doctor-S6KPGS35.js +27 -0
  11. package/dist/index.js +90 -80
  12. package/dist/{info-7FKBTMVO.js → info-NJEY26H6.js} +91 -46
  13. package/dist/{context-UJCGYOT6.js → inspect-5YZMJPFM.js} +10 -10
  14. package/dist/{install-v2-3KJX3YRO.js → install-v2-KGIDII4H.js} +163 -364
  15. package/dist/{store-HOCORVL3.js → store-GF4SFBMJ.js} +155 -57
  16. package/dist/{sync-DT5UJMMR.js → sync-3XCIRDPK.js} +3 -4
  17. package/dist/{uninstall-IFN2KYBK.js → uninstall-BG4ML4FC.js} +39 -10
  18. package/package.json +3 -7
  19. package/templates/hooks/cite-policy-evict.cjs +1 -1
  20. package/templates/hooks/configs/claude-code.json +1 -5
  21. package/templates/hooks/configs/codex-hooks.json +1 -5
  22. package/templates/hooks/fabric-hint.cjs +67 -41
  23. package/templates/hooks/knowledge-hint-broad.cjs +82 -24
  24. package/templates/hooks/knowledge-hint-narrow.cjs +3 -3
  25. package/templates/hooks/knowledge-pretooluse.cjs +111 -0
  26. package/templates/hooks/lib/banner-i18n.cjs +12 -11
  27. package/templates/hooks/lib/bindings-snapshot-reader.cjs +1 -1
  28. package/templates/hooks/lib/event-writer.cjs +79 -0
  29. package/templates/hooks/lib/nudge-policy.cjs +11 -0
  30. package/templates/hooks/lib/theme.cjs +62 -0
  31. package/templates/hooks/post-tooluse-mutation.cjs +28 -39
  32. package/templates/skills/fabric-archive/SKILL.md +29 -12
  33. package/templates/skills/fabric-archive/ref/dry-run-scope.md +1 -1
  34. package/templates/skills/fabric-archive/ref/i18n-policy.md +1 -1
  35. package/templates/skills/fabric-archive/ref/phase-1-5-onboard.md +5 -5
  36. package/templates/skills/fabric-archive/ref/phase-1-cross-session.md +2 -2
  37. package/templates/skills/fabric-archive/ref/phase-2-5-viability.md +1 -1
  38. package/templates/skills/fabric-archive/ref/phase-3-5-scope.md +1 -1
  39. package/templates/skills/fabric-archive/ref/phase-3-6-related-edges.md +1 -1
  40. package/templates/skills/fabric-archive/ref/phase-3-classify.md +1 -1
  41. package/templates/skills/fabric-archive/ref/phase-4-5-emit.md +1 -1
  42. package/templates/skills/fabric-archive/ref/phase-4-mcp-persist.md +6 -5
  43. package/templates/skills/{fabric-import/ref/checkpoint-state.md → fabric-archive/ref/source-checkpoint.md} +3 -3
  44. package/templates/skills/{fabric-import/ref/phase-3-dedup.md → fabric-archive/ref/source-dedup.md} +4 -4
  45. package/templates/skills/{fabric-import/ref/phase-2-mining.md → fabric-archive/ref/source-mining.md} +20 -20
  46. package/templates/skills/{fabric-import/ref/output-contract.md → fabric-archive/ref/source-output-contract.md} +3 -3
  47. package/templates/skills/{fabric-import/ref/state-recovery.md → fabric-archive/ref/source-state-recovery.md} +2 -2
  48. package/templates/skills/{fabric-import/ref/worked-examples.md → fabric-archive/ref/source-worked-examples.md} +10 -10
  49. package/templates/skills/fabric-archive/ref/worked-examples.md +3 -3
  50. package/templates/skills/fabric-review/SKILL.md +19 -15
  51. package/templates/skills/fabric-review/ref/cite-contract.md +2 -2
  52. package/templates/skills/fabric-review/ref/modify-flow.md +13 -1
  53. package/templates/skills/fabric-review/ref/per-mode-flows.md +5 -5
  54. package/templates/skills/fabric-review/ref/relate-mode.md +33 -0
  55. package/templates/skills/fabric-review/ref/retire-mode.md +47 -0
  56. package/templates/skills/fabric-review/ref/semantic-check.md +1 -1
  57. package/templates/skills/fabric-review/ref/worked-examples.md +5 -5
  58. package/templates/skills/fabric-store/SKILL.md +12 -27
  59. package/templates/skills/fabric-sync/SKILL.md +16 -35
  60. package/templates/skills/lib/shared-policy.md +6 -4
  61. package/dist/chunk-27HK6H5Y.js +0 -69
  62. package/dist/chunk-E7HJUU34.js +0 -1096
  63. package/dist/chunk-NLNH64A3.js +0 -43
  64. package/dist/chunk-QFIVFZRH.js +0 -13
  65. package/dist/doctor-MDTZWKBK.js +0 -24
  66. package/dist/metrics-HMFH4YHK.js +0 -135
  67. package/dist/scope-explain-HLJZ2M33.js +0 -48
  68. package/dist/status-4R3TM4FJ.js +0 -37
  69. package/dist/whoami-ITGEFWH4.js +0 -49
  70. package/templates/skills/fabric/SKILL.md +0 -100
  71. package/templates/skills/fabric-audit/SKILL.md +0 -63
  72. package/templates/skills/fabric-connect/SKILL.md +0 -48
  73. package/templates/skills/fabric-import/SKILL.md +0 -151
  74. package/templates/skills/fabric-import/ref/i18n-policy.md +0 -78
@@ -6,7 +6,11 @@ const { dirname, join } = require("node:path");
6
6
  // ledgers (events.jsonl, metrics.jsonl). Under multi-window concurrency a bare
7
7
  // appendFileSync can interleave a partial write; route through the advisory-lock
8
8
  // primitive (drop-on-contention, best-effort — matches injection-log).
9
+ // ux-w2-9: events.jsonl writes go through the single guarded event-writer
10
+ // (envelope stamp + event_type guard); metrics.jsonl stays on the raw locked
11
+ // primitive (it is not a schema-governed event ledger).
9
12
  const { appendLockedLine } = require("./lib/injection-log.cjs");
13
+ const { appendEvent } = require("./lib/event-writer.cjs");
10
14
 
11
15
  // v2.0.0-rc.7 T5: session-digest writer. Best-effort (never blocks Stop hook
12
16
  // on failure — see contract in lib/session-digest-writer.cjs).
@@ -976,11 +980,11 @@ function readArchiveEditThreshold(projectRoot) {
976
980
  * Both default to a no-trigger shape when omitted (back-compat for callers
977
981
  * pre-dating the two-lane split).
978
982
  *
979
- * Returns one of:
980
- * - { decision: 'block', reason, signal: 'archive', recommended_skill: 'fabric-archive' }
981
- * - { decision: 'block', reason, signal: 'archive_backlog', recommended_skill: 'fabric-archive' }
982
- * - { decision: 'block', reason, signal: 'review', recommended_skill: 'fabric-review' }
983
- * - { decision: 'block', reason, signal: 'import', recommended_skill: 'fabric-import' }
983
+ * Returns one of (ux-w0-3: `decision: 'soft'` — a reminder, never a gate):
984
+ * - { decision: 'soft', reason, signal: 'archive', recommended_skill: 'fabric-archive' }
985
+ * - { decision: 'soft', reason, signal: 'archive_backlog', recommended_skill: 'fabric-archive' }
986
+ * - { decision: 'soft', reason, signal: 'review', recommended_skill: 'fabric-review' }
987
+ * - { decision: 'soft', reason, signal: 'import', recommended_skill: 'fabric-import' }
984
988
  * - null on no trigger
985
989
  */
986
990
  // rc.7 T7: thresholds is the externalized-config view passed in by main().
@@ -1077,7 +1081,7 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
1077
1081
  const line3 = renderBanner("archiveCta", variant, {});
1078
1082
  const reason = [line1, line2, line3].filter((l) => l.length > 0).join("\n");
1079
1083
  return {
1080
- decision: "block",
1084
+ decision: "soft",
1081
1085
  reason,
1082
1086
  signal: "archive",
1083
1087
  recommended_skill: "fabric-archive",
@@ -1100,7 +1104,7 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
1100
1104
  const line2 = renderBanner("backlogCta", variant, {});
1101
1105
  const reason = `${line1}\n${line2}`;
1102
1106
  return {
1103
- decision: "block",
1107
+ decision: "soft",
1104
1108
  reason,
1105
1109
  signal: "archive_backlog",
1106
1110
  recommended_skill: "fabric-archive",
@@ -1138,7 +1142,7 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
1138
1142
  const line2 = renderBanner("reviewCta", variant, {});
1139
1143
  const reason = `${line1}\n${line2}`;
1140
1144
  return {
1141
- decision: "block",
1145
+ decision: "soft",
1142
1146
  reason,
1143
1147
  signal: "review",
1144
1148
  recommended_skill: "fabric-review",
@@ -1196,10 +1200,11 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
1196
1200
  const line2 = renderBanner("importCta", variant, {});
1197
1201
  const reason = `${line1}\n${line2}`;
1198
1202
  return {
1199
- decision: "block",
1203
+ decision: "soft",
1200
1204
  reason,
1201
1205
  signal: "import",
1202
- recommended_skill: "fabric-import",
1206
+ // W3-C: fabric-import folded into fabric-archive `source` mode.
1207
+ recommended_skill: "fabric-archive",
1203
1208
  // v2.1 NEW-N-3: underseed corpus trigger — node-count vs threshold. The
1204
1209
  // "import" signal collapses to schema signal_type "other" in main().
1205
1210
  threshold: underseed.threshold,
@@ -1525,8 +1530,8 @@ function writeMaintenanceLastEmit(projectRoot, nowMs, sessionId) {
1525
1530
  * the previous Signal-D emit. Tracked via dedicated sidecar
1526
1531
  * `.fabric/.cache/maintenance-hint-last-emit`.
1527
1532
  *
1528
- * Returns one of:
1529
- * - { decision: 'block', reason, signal: 'maintenance', recommended_skill: null }
1533
+ * Returns one of (ux-w0-3: `decision: 'soft'` — a reminder, never a gate):
1534
+ * - { decision: 'soft', reason, signal: 'maintenance', recommended_skill: null }
1530
1535
  * - null on no trigger
1531
1536
  *
1532
1537
  * `recommended_skill` is intentionally null — the maintenance prompt
@@ -1591,7 +1596,7 @@ function evaluateMaintenanceSignal(events, now, canonicalCount, lastEmitMs, thre
1591
1596
  const reason = `${line1}\n${line2}`;
1592
1597
 
1593
1598
  return {
1594
- decision: "block",
1599
+ decision: "soft",
1595
1600
  reason,
1596
1601
  signal: "maintenance",
1597
1602
  // CLI recommendation rather than Skill — doctor is a CLI surface.
@@ -1681,7 +1686,7 @@ function emitGraphEdgeCandidateBestEffort(cwd, events, sessionId) {
1681
1686
  };
1682
1687
  if (store !== undefined) event.store = store;
1683
1688
  if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
1684
- appendLockedLine(join(fabricDir, EVENT_LEDGER_FILE), JSON.stringify(event) + "\n");
1689
+ appendEvent(fabricDir, event);
1685
1690
 
1686
1691
  // Record the de-dup marker (best-effort; atomic when state-store lib loaded).
1687
1692
  try {
@@ -1743,7 +1748,7 @@ function emitSignalFiredEvent(cwd, sessionId, result) {
1743
1748
  fired: true,
1744
1749
  };
1745
1750
  if (typeof sessionId === "string" && sessionId.length > 0) event.session_id = sessionId;
1746
- appendLockedLine(join(fabricDir, EVENT_LEDGER_FILE), JSON.stringify(event) + "\n");
1751
+ appendEvent(fabricDir, event);
1747
1752
  } catch {
1748
1753
  // best-effort telemetry — never block the hook
1749
1754
  }
@@ -1896,7 +1901,6 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
1896
1901
  // writer applies the same guard via its own internal check.
1897
1902
  return;
1898
1903
  }
1899
- const ledgerPath = join(fabricDir, EVENT_LEDGER_FILE);
1900
1904
  const client = detectClient();
1901
1905
  let randomUUID;
1902
1906
  try {
@@ -1952,7 +1956,7 @@ function extractAndWriteAssistantTurnsBestEffort(cwd, stdinPayload) {
1952
1956
  timestamp: new Date().toISOString(),
1953
1957
  };
1954
1958
  if (client !== undefined) event.client = client;
1955
- appendLockedLine(ledgerPath, JSON.stringify(event) + "\n");
1959
+ appendEvent(fabricDir, event);
1956
1960
  } catch {
1957
1961
  // Per-turn failure must not abort the remaining turns; the Stop hook
1958
1962
  // contract is "never block on hook failure". Best-effort continues.
@@ -2220,6 +2224,45 @@ function writeSessionDigestBestEffort(projectRoot, stdinPayload) {
2220
2224
  }
2221
2225
  }
2222
2226
 
2227
+ // ux-w0-3 (KT-DEC-0007): the SINGLE soft-emit path for EVERY Stop-hook signal
2228
+ // (archive / archive_backlog / review / import / maintenance). A nudge is a
2229
+ // reminder layer, NEVER a gate — so no signal ever emits a blocking decision.
2230
+ // The AI channel always carries the reason (flow ⊥ observation, D3); the human
2231
+ // systemMessage is gated by nudge_mode (D4/D5), with high-value signals
2232
+ // (knowledge-loss: archive / archive_backlog) surfacing at lower volumes.
2233
+ // Mutates `result` (strips telemetry-only threshold/actual_value, like the prior
2234
+ // inline paths). When the client adapter is unavailable, falls back to a plain
2235
+ // non-blocking JSON payload (decision stays "soft", never blocking).
2236
+ function emitSoftSignal(out, result, cwd, highValue) {
2237
+ const reasonText = typeof result.reason === "string" ? result.reason : "";
2238
+ delete result.threshold;
2239
+ delete result.actual_value;
2240
+ const client =
2241
+ clientAdapter && typeof clientAdapter.detectClient === "function"
2242
+ ? clientAdapter.detectClient(__dirname)
2243
+ : undefined;
2244
+ // Known client (cc / codex): emit the dual-sink envelope on stdout —
2245
+ // additionalContext(AI, always) + systemMessage(human, gated by nudge_mode).
2246
+ if (client && clientAdapter && typeof clientAdapter.emitDualSink === "function") {
2247
+ const humanGate =
2248
+ nudgePolicy !== null
2249
+ ? nudgePolicy.resolveHumanSink(cwd, "stop", { highValue })
2250
+ : { emitHuman: true };
2251
+ clientAdapter.emitDualSink(
2252
+ { human: humanGate.emitHuman ? reasonText : null, ai: reasonText },
2253
+ { client, eventName: "Stop", streams: { stdout: out } },
2254
+ );
2255
+ return;
2256
+ }
2257
+ // Unknown client / no adapter → emit the reason as a plain, non-blocking
2258
+ // payload on stdout. `result.decision` is "soft" (non-blocking), so this is
2259
+ // a reminder, not a gate (KT-DEC-0007).
2260
+ out.write(JSON.stringify(result));
2261
+ }
2262
+
2263
+ // High-value (knowledge-loss) signals surface at lower nudge_mode volumes.
2264
+ const HIGH_VALUE_SIGNALS = new Set(["archive", "archive_backlog"]);
2265
+
2223
2266
  /**
2224
2267
  * Main entry — invoked both as a CLI (require.main === module) and in-process by tests.
2225
2268
  *
@@ -2492,9 +2535,7 @@ function main(env, stdio) {
2492
2535
  // uses hours, so we branch here to avoid mixing semantics.
2493
2536
  if (result.signal === "maintenance") {
2494
2537
  emitSignalFiredEvent(cwd, sessionId, result);
2495
- delete result.threshold;
2496
- delete result.actual_value;
2497
- out.write(JSON.stringify(result));
2538
+ emitSoftSignal(out, result, cwd, HIGH_VALUE_SIGNALS.has(result.signal));
2498
2539
  writeMaintenanceLastEmit(cwd, nowMs, resolveHookSessionId(stdinPayload));
2499
2540
  return;
2500
2541
  }
@@ -2516,27 +2557,12 @@ function main(env, stdio) {
2516
2557
  }
2517
2558
 
2518
2559
  emitSignalFiredEvent(cwd, sessionId, result);
2519
- const reasonText = typeof result.reason === "string" ? result.reason : "";
2520
- delete result.threshold;
2521
- delete result.actual_value;
2522
- // v2.2 dual-sink (Goal A / D3): the archive nudge is SOFT — emitted as
2523
- // additionalContext(AI) + systemMessage(human), NEVER decision:block. The
2524
- // human channel is gated by nudge_mode (D4/D5); the AI channel always carries
2525
- // it (flow ⊥ observation). Missing it is backstopped by the SessionEnd marker
2526
- // + cross-session debt (D3). Review/import keep the decision:block contract
2527
- // (out of Goal A scope; KT-DEC-0007 nudge semantics unchanged for them).
2528
- if (result.signal === "archive" && clientAdapter && typeof clientAdapter.emitDualSink === "function") {
2529
- const humanGate =
2530
- nudgePolicy !== null
2531
- ? nudgePolicy.resolveHumanSink(cwd, "stop", { highValue: true })
2532
- : { emitHuman: true };
2533
- clientAdapter.emitDualSink(
2534
- { human: humanGate.emitHuman ? reasonText : null, ai: reasonText },
2535
- { client: clientAdapter.detectClient(__dirname), eventName: "Stop", streams: { stdout: out } },
2536
- );
2537
- } else {
2538
- out.write(JSON.stringify(result));
2539
- }
2560
+ // ux-w0-3 (KT-DEC-0007): EVERY A/B/C signal (archive / archive_backlog /
2561
+ // review / import) emits SOFT via the shared path — additionalContext(AI) +
2562
+ // nudge_mode-gated systemMessage(human), NEVER decision:block. Previously
2563
+ // only `archive` was soft and review/import blocked; the block contract is
2564
+ // retired (a nudge is a reminder layer, never a gate).
2565
+ emitSoftSignal(out, result, cwd, HIGH_VALUE_SIGNALS.has(result.signal));
2540
2566
  cache[result.signal] = nowMs;
2541
2567
  writeShownCache(cwd, cache, resolveHookSessionId(stdinPayload));
2542
2568
  } catch {
@@ -42,11 +42,21 @@ const { spawnSync } = require("node:child_process");
42
42
  const { existsSync, readdirSync, readFileSync } = require("node:fs");
43
43
  const { join } = require("node:path");
44
44
 
45
+ // W3-B F-005 (C-003 HUD-shared layer): the SessionStart AI sink reskin consumes
46
+ // the parity-trivial shared structural primitives — sectionBar (title row) +
47
+ // scopeBadge ([team]/[project]/[personal]) — from the .cjs theme mirror. This is
48
+ // the ONLY shared-theme dependency the hook may take: lib/theme.cjs is byte-locked
49
+ // to packages/shared/src/theme.ts by theme-parity.test.ts (G-THEME). The hook MUST
50
+ // NEVER reach for the CLI-only ESM/TS structure layer (the tree / grid primitives
51
+ // under packages/cli/src/tui) — it is unrequireable from a .cjs runtime; the HUD
52
+ // deliberately uses plain two-space indent instead of the complex tree() primitive.
53
+ const { sectionBar, scopeBadge } = require("./lib/theme.cjs");
54
+
45
55
  // W1-01 (ISS-012): the SessionStart broad hook appends a hook_surface_emitted
46
- // event to the shared events.jsonl. Under multi-window concurrency a bare
47
- // appendFileSync can interleave a partial write; route through the advisory-lock
48
- // primitive (drop-on-contention, best-effort matches injection-log).
49
- const { appendLockedLine } = require("./lib/injection-log.cjs");
56
+ // event to the shared events.jsonl. ux-w2-9: route through the single guarded
57
+ // event-writer (envelope stamp + event_type guard + advisory-lock append) so
58
+ // the row always satisfies the event-ledger schema the doctor reads.
59
+ const { appendEvent } = require("./lib/event-writer.cjs");
50
60
 
51
61
  // rc.16 TASK-003: shared banner-i18n lib (resolves fabric_language config and
52
62
  // renders localized banner text). Mirror of the wiring in fabric-hint.cjs
@@ -330,7 +340,7 @@ function isImportTouched(projectRoot) {
330
340
 
331
341
  /**
332
342
  * rc.8 underseed self-check: determine whether the SessionStart hook should
333
- * surface the one-line `/fabric-import` recommendation banner.
343
+ * surface the one-line `/fabric-archive` recommendation banner.
334
344
  *
335
345
  * Three-condition truth table (ALL must hold to return true):
336
346
  * 1. the workspace is fabric-bound — readWorkspaceBindingId(cwd) !== null
@@ -441,7 +451,7 @@ const MATURITY_DRAFT = "draft";
441
451
  // fabric-hint.cjs Signal C `📋 Fabric:`). rc.16 TASK-003 routed the literal
442
452
  // through the banner-i18n lib (key: 'broadImportBanner') — see main() below
443
453
  // for the renderBanner call site. Substring contracts preserved across all
444
- // variants: leading two-space indent, `📋 Fabric:` prefix, `/fabric-import`
454
+ // variants: leading two-space indent, `📋 Fabric:` prefix, `/fabric-archive`
445
455
  // verbatim token (asserted by knowledge-hint-broad.test.ts).
446
456
 
447
457
  // -----------------------------------------------------------------------------
@@ -921,6 +931,24 @@ function renderScopeStoreLabel(snapshot, lang) {
921
931
  // must_read_if}); `alwaysBodies` is always_bodies[] ({id,type,layer,summary,body}).
922
932
  const REFERENCE_TYPES = new Set(["decision", "pitfall", "process"]);
923
933
 
934
+ // W3-B F-005: map a knowledge entry's layer to a scopeBadge() scope key
935
+ // (team→'team' · project→'project' · personal→'personal'). Bodies carry an
936
+ // explicit `.layer`; plan-context reference entries do not, so the layer is
937
+ // recovered from the id prefix (`team:KT-…` / `project:…` / `personal:…`), with a
938
+ // `KP-` id always personal. Anything unrecognized falls back to 'team' — the
939
+ // broadest, default tier — so the badge never renders an undefined scope.
940
+ const VALID_SCOPES = new Set(["team", "project", "personal"]);
941
+ function layerToScope(entry) {
942
+ const e = entry || {};
943
+ if (typeof e.layer === "string" && VALID_SCOPES.has(e.layer)) return e.layer;
944
+ const id = typeof e.id === "string" ? e.id : "";
945
+ const prefix = id.includes(":") ? id.slice(0, id.indexOf(":")) : "";
946
+ if (VALID_SCOPES.has(prefix)) return prefix;
947
+ // `KP-*` ids are the personal convention even without a `personal:` prefix.
948
+ if (/(^|:)KP-/.test(id)) return "personal";
949
+ return "team";
950
+ }
951
+
924
952
  function renderAiSink(opts) {
925
953
  const { entries, alwaysBodies, storeLabel, broadIndexBackstop, summaryMaxLen, lang } =
926
954
  opts || {};
@@ -939,35 +967,56 @@ function renderAiSink(opts) {
939
967
  let indexCount = 0; // total rendered index lines (always + reference), for the backstop.
940
968
 
941
969
  const lines = [];
942
- lines.push(`[fabric:SessionStart] ${storeLabel || "store"}`);
970
+ // W3-B F-005 (mockup #3): a single sectionBar title carrying the active /
971
+ // reference counts, replacing the legacy `[fabric:SessionStart] <store>` +
972
+ // flat `ALWAYS-ACTIVE RULES (...)` header. The store label is preserved on the
973
+ // human sink (renderScopeStoreLabel); the AI sink leads with the count title.
974
+ // sectionBar degrades to `# <title>` when color is off (NO_COLOR / non-TTY),
975
+ // byte-locked to the TS source by theme-parity.
976
+ lines.push(
977
+ sectionBar(
978
+ zh
979
+ ? `Fabric Knowledge · ${bodies.length} 常驻 · ${referenceEntries.length} 参考`
980
+ : `Fabric Knowledge · ${bodies.length} active · ${referenceEntries.length} reference`,
981
+ ),
982
+ );
943
983
 
944
- // ALWAYS-ACTIVE RULES index-only (title + summary), never the eager body.
984
+ // ALWAYS-ACTIVE — plain sub-section label (the `RULES (...)` qualifier is kept
985
+ // so the contract substring + framing survive the reskin). Index-only (title +
986
+ // summary), never the eager body.
945
987
  lines.push(
946
988
  zh
947
- ? "ALWAYS-ACTIVE RULES (无条件适用 · 照此行遵循,正文按需取):"
948
- : "ALWAYS-ACTIVE RULES (unconditional · act on the line; body on demand):",
989
+ ? " ALWAYS-ACTIVE RULES (无条件适用 · 照此行遵循,正文按需取):"
990
+ : " ALWAYS-ACTIVE RULES (unconditional · act on the line; body on demand):",
949
991
  );
950
992
  if (bodies.length === 0) {
951
993
  lines.push(zh ? " (无 always-active 条目)" : " (none)");
952
994
  } else {
953
995
  // KT-DEC-0036: render each always-active entry as a single index line
954
- // (title + summary). The body is one cheap on-demand fetch away (see footer),
955
- // so injecting it on every SessionStart is a permanent context tax
956
- // (KT-GLD-0005) we no longer pay.
996
+ // (scope badge + title + summary). The body is one cheap on-demand fetch away
997
+ // (see footer), so injecting it on every SessionStart is a permanent context
998
+ // tax (KT-GLD-0005) we no longer pay.
957
999
  for (const b of bodies) {
1000
+ const badge = scopeBadge(layerToScope(b));
958
1001
  const label = `[${TYPE_SINGULAR[b.type] || b.type}] ${b.id}`;
959
- const summary = typeof b.summary === "string" ? b.summary.trim() : "";
960
- lines.push(summary.length > 0 ? ` ${label} · ${summary}` : ` ${label}`);
1002
+ // ux-w1-3: bound the always-active summary by hint_summary_max_len, mirroring
1003
+ // the REFERENCE must_read_if hook below one long entry must not blow up the
1004
+ // SessionStart sink.
1005
+ const raw = typeof b.summary === "string" ? b.summary.trim() : "";
1006
+ const summary = truncateSummary(raw, summaryMaxLen);
1007
+ lines.push(
1008
+ summary.length > 0 ? ` ${badge} ${label} · ${summary}` : ` ${badge} ${label}`,
1009
+ );
961
1010
  indexCount += 1;
962
1011
  }
963
1012
  }
964
1013
 
965
- // REFERENCE — broad decision/pitfall/process: title + must_read_if hook.
1014
+ // REFERENCE — broad decision/pitfall/process: scope badge + title + must_read_if hook.
966
1015
  if (referenceEntries.length > 0) {
967
1016
  lines.push(
968
1017
  zh
969
- ? "REFERENCE (情境触发 · 命中 must_read_if 时 Read / fab_recall):"
970
- : "REFERENCE (situational · Read when must_read_if fires / fab_recall):",
1018
+ ? " REFERENCE (情境触发 · 命中 must_read_if 时 Read / fab_recall):"
1019
+ : " REFERENCE (situational · Read when must_read_if fires / fab_recall):",
971
1020
  );
972
1021
  let folded = 0;
973
1022
  for (const e of referenceEntries) {
@@ -975,6 +1024,7 @@ function renderAiSink(opts) {
975
1024
  folded += 1;
976
1025
  continue;
977
1026
  }
1027
+ const badge = scopeBadge(layerToScope(e));
978
1028
  const type = TYPE_SINGULAR[toPluralType(e.type)] || e.type;
979
1029
  const rawHook =
980
1030
  typeof e.must_read_if === "string" && e.must_read_if.length > 0
@@ -983,7 +1033,11 @@ function renderAiSink(opts) {
983
1033
  ? e.summary
984
1034
  : "";
985
1035
  const hookText = truncateSummary(rawHook, summaryMaxLen);
986
- lines.push(hookText.length > 0 ? ` [${type}] ${e.id} — ${hookText}` : ` [${type}] ${e.id}`);
1036
+ lines.push(
1037
+ hookText.length > 0
1038
+ ? ` ${badge} [${type}] ${e.id} — ${hookText}`
1039
+ : ` ${badge} [${type}] ${e.id}`,
1040
+ );
987
1041
  indexCount += 1;
988
1042
  }
989
1043
  // D0028 backstop: fold the overflow tail into one marker + drift signal.
@@ -1022,7 +1076,7 @@ function renderAiSink(opts) {
1022
1076
  // Block 5 (Option X): build the two SessionStart sinks (human systemMessage +
1023
1077
  // AI additionalContext) from a plan-context-hint payload, WITHOUT emitting or
1024
1078
  // recording telemetry. This is the single shared renderer: main() calls it then
1025
- // emits + logs; `fabric context` calls it then prints (byte-identical injection
1079
+ // emits + logs; `fabric inspect` calls it then prints (byte-identical injection
1026
1080
  // by construction — same code, same config/FS reads). Pure-ish: it reads config
1027
1081
  // + snapshot + .md summaries for `cwd` but has no stdout/ledger side effects.
1028
1082
  //
@@ -1124,8 +1178,8 @@ function buildSessionStartSinks(cwd, payload, env) {
1124
1178
  if (humanLines.length > 0) {
1125
1179
  humanLines.push(
1126
1180
  fabricLanguageForEmit === "zh-CN"
1127
- ? " 看具体注入: fabric context (--explain 看每条来源)"
1128
- : " Inspect this injection: fabric context (--explain for per-entry provenance)",
1181
+ ? " 看具体注入: fabric inspect (--explain 看每条来源)"
1182
+ : " Inspect this injection: fabric inspect (--explain for per-entry provenance)",
1129
1183
  );
1130
1184
  }
1131
1185
 
@@ -1190,7 +1244,7 @@ function main(env, stdio) {
1190
1244
  // per-line char cap + broad_index_backstop fold, not by dropping entries.
1191
1245
 
1192
1246
  // Block 5 (Option X): build both sinks via the shared renderer (same code
1193
- // `fabric context` uses → byte-identical injection). Side-effect-free; the
1247
+ // `fabric inspect` uses → byte-identical injection). Side-effect-free; the
1194
1248
  // emit + telemetry below stay in main().
1195
1249
  const { human, ai, resolvedPayload, hasRenderedContent, reminderToContext } =
1196
1250
  buildSessionStartSinks(cwd, payload, env);
@@ -1273,7 +1327,7 @@ function main(env, stdio) {
1273
1327
  rendered_ids: renderedIds,
1274
1328
  delivery_status: "delivered",
1275
1329
  };
1276
- appendLockedLine(join(fabricDir, "events.jsonl"), JSON.stringify(surfaceEvent) + "\n");
1330
+ appendEvent(fabricDir, surfaceEvent);
1277
1331
  }
1278
1332
  } catch {
1279
1333
  // best-effort telemetry — never block session start
@@ -1299,6 +1353,10 @@ module.exports = {
1299
1353
  renderFull,
1300
1354
  renderTruncated,
1301
1355
  renderSummary,
1356
+ // W3-B F-005: the reskinned SessionStart AI sink + its layer→scope mapper,
1357
+ // exported for the NO_COLOR snapshot test (hud-reskin.test.ts).
1358
+ renderAiSink,
1359
+ layerToScope,
1302
1360
  truncateSummary,
1303
1361
  // rc.8 underseed self-check helpers (exported for unit testing).
1304
1362
  countCanonicalNodes,
@@ -19,7 +19,7 @@
19
19
  * [<id>] (<type>/<maturity>) <summary-line>
20
20
  * [<id>] (<type>/<maturity>) <summary-line>
21
21
  * ...
22
- * (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)
22
+ * (如需重读 broad 决策,跑 fabric plan-context-hint --all)
23
23
  *
24
24
  * When narrow.length === 0: complete silence (exit 0, no stderr).
25
25
  *
@@ -1215,7 +1215,7 @@ function readSummaryMaxLen(projectRoot) {
1215
1215
  * [fabric] N narrow-scoped knowledge entries match your edit targets:
1216
1216
  * [<id>] (<type>/<maturity>) <summary>
1217
1217
  * ...
1218
- * (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)
1218
+ * (如需重读 broad 决策,跑 fabric plan-context-hint --all)
1219
1219
  */
1220
1220
  function renderSummary(payload, maxLen) {
1221
1221
  if (!payload || payload.version !== 2) {
@@ -1242,7 +1242,7 @@ function renderSummary(payload, maxLen) {
1242
1242
  for (const entry of entries) {
1243
1243
  lines.push(formatEntryLine(entry, maxLen));
1244
1244
  }
1245
- lines.push(" (如需重读 broad 决策,调 fab_plan_context 或 fabric plan-context-hint --all)");
1245
+ lines.push(" (如需重读 broad 决策,跑 fabric plan-context-hint --all)");
1246
1246
  return lines;
1247
1247
  }
1248
1248
 
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env node
2
+ // ux-w2-6: the SINGLE PreToolUse hook. Previously the Edit|Write|MultiEdit
3
+ // matcher carried TWO commands — knowledge-hint-narrow.cjs (narrow KB hint) and
4
+ // cite-policy-evict.cjs (recall-before-edit nudge) — so a single edit produced
5
+ // TWO additionalContext envelopes (双弹). This orchestrator runs both in ONE
6
+ // process and merges their output into ONE envelope.
7
+ //
8
+ // narrow.cjs and cite-policy-evict.cjs stay as standalone modules (their full
9
+ // contract test-suites are unchanged); this entry imports them as libs, reads
10
+ // stdin ONCE (stdin is single-read), hands the parsed payload to each via the
11
+ // `env.payload` test seam, captures each one's stdout, and folds the two
12
+ // envelopes into a single `{ systemMessage?, hookSpecificOutput.additionalContext? }`.
13
+ // Each sub-hook stays best-effort/silent-on-failure, so a throw in one never
14
+ // blocks the edit or suppresses the other (KT-DEC-0007).
15
+
16
+ const narrow = require("./knowledge-hint-narrow.cjs");
17
+ const cite = require("./cite-policy-evict.cjs");
18
+
19
+ function readStdinPayload() {
20
+ try {
21
+ const raw = require("node:fs").readFileSync(0, "utf8");
22
+ if (!raw || raw.trim().length === 0) return null;
23
+ return JSON.parse(raw);
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ // Parse a captured stdout chunk as a Claude Code hook envelope. Returns null for
30
+ // empty / non-JSON output (e.g. a stderr-only codex breadcrumb leaves stdout
31
+ // empty). Tolerant: a malformed line is ignored, never thrown.
32
+ function parseEnvelope(text) {
33
+ const trimmed = typeof text === "string" ? text.trim() : "";
34
+ if (trimmed.length === 0) return null;
35
+ try {
36
+ return JSON.parse(trimmed);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ // Fold narrow + cite envelopes into one. additionalContext (AI sink) is
43
+ // concatenated (narrow hint first, then the cite nudge); systemMessage (human
44
+ // sink) likewise. Either side may be absent.
45
+ function mergeEnvelopes(narrowText, citeText) {
46
+ const a = parseEnvelope(narrowText);
47
+ const b = parseEnvelope(citeText);
48
+ if (a === null && b === null) return null;
49
+
50
+ const aiParts = [];
51
+ const humanParts = [];
52
+ let eventName = "PreToolUse";
53
+ for (const env of [a, b]) {
54
+ if (env === null) continue;
55
+ const hso = env.hookSpecificOutput;
56
+ if (hso && typeof hso.additionalContext === "string" && hso.additionalContext.length > 0) {
57
+ aiParts.push(hso.additionalContext);
58
+ if (typeof hso.hookEventName === "string") eventName = hso.hookEventName;
59
+ }
60
+ if (typeof env.systemMessage === "string" && env.systemMessage.length > 0) {
61
+ humanParts.push(env.systemMessage);
62
+ }
63
+ }
64
+
65
+ const merged = {};
66
+ if (humanParts.length > 0) merged.systemMessage = humanParts.join("\n");
67
+ if (aiParts.length > 0) {
68
+ merged.hookSpecificOutput = {
69
+ hookEventName: eventName,
70
+ additionalContext: aiParts.join("\n"),
71
+ };
72
+ }
73
+ return Object.keys(merged).length > 0 ? `${JSON.stringify(merged)}\n` : null;
74
+ }
75
+
76
+ async function main(env, stdio) {
77
+ try {
78
+ const out = (stdio && stdio.stdout) || process.stdout;
79
+ const err = (stdio && stdio.stderr) || process.stderr;
80
+ // Read stdin ONCE and share the parsed payload with both sub-hooks (stdin is
81
+ // a single-read stream — the prior two-command wiring read it twice).
82
+ const payload = env && env.payload !== undefined ? env.payload : readStdinPayload();
83
+ const sub = { ...(env || {}), payload };
84
+
85
+ const narrowChunks = [];
86
+ const citeChunks = [];
87
+ const capture = (sink) => ({ write: (c) => sink.push(String(c)) });
88
+
89
+ try {
90
+ await narrow.main(sub, { stdout: capture(narrowChunks), stderr: err });
91
+ } catch {
92
+ // narrow best-effort — never block the edit
93
+ }
94
+ try {
95
+ await cite.main(sub, { stdout: capture(citeChunks), stderr: err });
96
+ } catch {
97
+ // cite best-effort — never block the edit
98
+ }
99
+
100
+ const merged = mergeEnvelopes(narrowChunks.join(""), citeChunks.join(""));
101
+ if (merged !== null) out.write(merged);
102
+ } catch {
103
+ // Silent — the PreToolUse hook MUST NEVER block the edit on its own failure.
104
+ }
105
+ }
106
+
107
+ module.exports = { main, mergeEnvelopes, parseEnvelope };
108
+
109
+ if (require.main === module) {
110
+ main();
111
+ }
@@ -37,7 +37,7 @@
37
37
  * Broad hook: broadImportBanner
38
38
  *
39
39
  * Protected tokens — NEVER translated, kept verbatim across all 4 variants:
40
- * - Slash commands: /fabric-archive, /fabric-review, /fabric-import
40
+ * - Slash commands: /fabric-archive, /fabric-review, /fabric-archive
41
41
  * - CLI commands: `fabric doctor --lint`
42
42
  * - Numeric / template substrings the existing tests assert on:
43
43
  * "${hoursElapsed.toFixed(1)}h" (e.g. "25.0h"), "阈值 ${N}h",
@@ -222,12 +222,13 @@ const STRINGS = {
222
222
  `📋 Fabric: 知识库节点数 ${p.nodeCount}/${p.threshold},距 init_scan_completed ${p.hoursSinceInit}h。`,
223
223
  },
224
224
 
225
- // Source (zh-CN): fabric-hint.cjs:698 ` 是否调 /fabric-import git 历史与现有文档回灌知识?`
226
- // params: {} protected token /fabric-import verbatim across all variants.
225
+ // W3-C: fabric-import folded into fabric-archive `source` mode — the underseed
226
+ // cold-start nudge now points at /fabric-archive (its source mode). Protected
227
+ // token /fabric-archive verbatim across all variants.
227
228
  importCta: {
228
- "zh-CN": () => " 是否调 /fabric-import 从 git 历史与现有文档回灌知识?",
229
- en: () => " Run /fabric-import to backfill knowledge from git history and existing docs?",
230
- "zh-CN-hybrid": () => " 是否调 /fabric-import 从 git 历史与现有文档回灌知识?",
229
+ "zh-CN": () => " 是否调 /fabric-archive 的 source mode 从 git 历史与现有文档回灌知识?",
230
+ en: () => " Run /fabric-archive source mode to backfill knowledge from git history and existing docs?",
231
+ "zh-CN-hybrid": () => " 是否调 /fabric-archive 的 source mode 从 git 历史与现有文档回灌知识?",
231
232
  },
232
233
 
233
234
  // ---- Signal D: maintenance -----------------------------------------------
@@ -289,15 +290,15 @@ const STRINGS = {
289
290
 
290
291
  // ---- Broad hook: import recommendation ------------------------------------
291
292
  // Source (zh-CN): knowledge-hint-broad.cjs:262
292
- // " 📋 Fabric: 知识库稀疏,是否调 /fabric-import 从 git 历史与现有文档回灌知识?"
293
+ // " 📋 Fabric: 知识库稀疏,是否调 /fabric-archive 从 git 历史与现有文档回灌知识?"
293
294
  // Note: leading two spaces are intentional (existing banner indent).
294
- // params: {} — protected token /fabric-import verbatim.
295
+ // params: {} — protected token /fabric-archive verbatim.
295
296
  broadImportBanner: {
296
- "zh-CN": () => " 📋 Fabric: 知识库稀疏,是否调 /fabric-import 从 git 历史与现有文档回灌知识?",
297
+ "zh-CN": () => " 📋 Fabric: 知识库稀疏,是否调 /fabric-archive 从 git 历史与现有文档回灌知识?",
297
298
  en: () =>
298
- " 📋 Fabric: knowledge base is sparse — run /fabric-import to backfill from git history and existing docs?",
299
+ " 📋 Fabric: knowledge base is sparse — run /fabric-archive to backfill from git history and existing docs?",
299
300
  "zh-CN-hybrid": () =>
300
- " 📋 Fabric: 知识库稀疏,是否调 /fabric-import 从 git 历史与现有文档回灌知识?",
301
+ " 📋 Fabric: 知识库稀疏,是否调 /fabric-archive 从 git 历史与现有文档回灌知识?",
301
302
  },
302
303
 
303
304
  // ---- Broad hook: meta auto-refresh breadcrumb (rc.22 Scope D T-D4) -------
@@ -153,7 +153,7 @@ function liveKnowledgeStats(snapshot) {
153
153
  // out-of-band (store grew via git pull / cross-workspace sync), so trusting it
154
154
  // re-introduced exactly the false-nudge this whole field cures — observed a
155
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
156
+ // mis-firing the "knowledge sparse → /fabric-archive" underseed nudge AND
157
157
  // defeating the fabric-import `canonical > 50 → SKIP` guard. read_set carries
158
158
  // no resolved store root either (alias/uuid only), so a live recount is
159
159
  // impossible without re-resolution (which hooks must not do). Return null