@fenglimg/fabric-cli 2.2.0-rc.9 → 2.2.0

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.
@@ -97,6 +97,8 @@ async function runPlanContextHint(opts) {
97
97
  () => ({
98
98
  by_type: {},
99
99
  by_layer: { team: 0, personal: 0, project: 0 },
100
+ broad_by_type: {},
101
+ narrow_total: 0,
100
102
  dropped_other_project: 0,
101
103
  total: 0
102
104
  })
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runPlanContextHint
4
- } from "./chunk-YM4XATJF.js";
4
+ } from "./chunk-722JU5BP.js";
5
5
  import "./chunk-WA3DYGSY.js";
6
6
 
7
7
  // src/commands/context.ts
package/dist/index.js CHANGED
@@ -23,7 +23,7 @@ import { defineCommand, renderUsage, runCommand, runMain } from "citty";
23
23
  // src/commands/index.ts
24
24
  var allCommands = {
25
25
  // v2.2.0-rc.5: pipeline-based install with TUI renderer (EPIC-005/006/007/008)
26
- install: () => import("./install-v2-I6PJ6IFT.js").then((module) => module.installCommand),
26
+ install: () => import("./install-v2-3KJX3YRO.js").then((module) => module.installCommand),
27
27
  // v2.1.0-rc.1 P3: multi-store lifecycle command group (list/add/remove/explain).
28
28
  store: () => import("./store-HOCORVL3.js").then((module) => module.default),
29
29
  // v2.1.0-rc.1 P3 (S9/S17/S37): multi-store pull --rebase + push, conflict resume.
@@ -38,7 +38,7 @@ var allCommands = {
38
38
  doctor: () => import("./doctor-MDTZWKBK.js").then((module) => module.default),
39
39
  uninstall: () => import("./uninstall-IFN2KYBK.js").then((module) => module.default),
40
40
  config: () => import("./config-A3LTECAY.js").then((module) => module.default),
41
- "plan-context-hint": () => import("./plan-context-hint-G75R4P4J.js").then((module) => module.default),
41
+ "plan-context-hint": () => import("./plan-context-hint-5TNGH3R4.js").then((module) => module.default),
42
42
  // v2.0.0-rc.23 TASK-014 (F8c): S5 onboard-slot coverage. Used by the
43
43
  // fabric-archive Skill's first-run phase to detect unclaimed slots.
44
44
  "onboard-coverage": () => import("./onboard-coverage-XSG77LL3.js").then((module) => module.default),
@@ -46,7 +46,7 @@ var allCommands = {
46
46
  metrics: () => import("./metrics-HMFH4YHK.js").then((module) => module.default),
47
47
  // Block 5 (Option X): show what SessionStart injects (shared renderer with the
48
48
  // hook → byte-identical). --explain for per-entry provenance.
49
- context: () => import("./context-7NUKXDB6.js").then((module) => module.default)
49
+ context: () => import("./context-UJCGYOT6.js").then((module) => module.default)
50
50
  };
51
51
 
52
52
  // src/lib/error-render.ts
@@ -153,7 +153,7 @@ async function customShowUsageGrouped(cmd, parent, version) {
153
153
  var main = defineCommand({
154
154
  meta: {
155
155
  name: "fabric",
156
- version: "2.2.0-rc.9",
156
+ version: "2.2.0",
157
157
  description: t("cli.main.description")
158
158
  },
159
159
  subCommands: allCommands
@@ -165,7 +165,7 @@ async function customShowUsage(cmd, parent) {
165
165
  return;
166
166
  }
167
167
  if (cmdMeta?.name === "fabric" && parent === void 0) {
168
- await customShowUsageGrouped(cmd, parent, "2.2.0-rc.9");
168
+ await customShowUsageGrouped(cmd, parent, "2.2.0");
169
169
  return;
170
170
  }
171
171
  console.log(await renderUsage(cmd, parent) + "\n");
@@ -1738,7 +1738,7 @@ function readProjectName(target) {
1738
1738
  return basename(target);
1739
1739
  }
1740
1740
  function getCliVersion() {
1741
- return true ? "2.2.0-rc.9" : "unknown";
1741
+ return true ? "2.2.0" : "unknown";
1742
1742
  }
1743
1743
  function sortRecord(record) {
1744
1744
  return Object.fromEntries(Object.entries(record).sort(([left], [right]) => left.localeCompare(right)));
@@ -3,7 +3,7 @@ import {
3
3
  planContextHintCommand,
4
4
  plan_context_hint_default,
5
5
  runPlanContextHint
6
- } from "./chunk-YM4XATJF.js";
6
+ } from "./chunk-722JU5BP.js";
7
7
  import "./chunk-WA3DYGSY.js";
8
8
  export {
9
9
  plan_context_hint_default as default,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fenglimg/fabric-cli",
3
- "version": "2.2.0-rc.9",
3
+ "version": "2.2.0",
4
4
  "description": "Fabric CLI — installs the MCP server + skills + hooks for Claude Code and Codex CLI; runs doctor / knowledge maintenance.",
5
5
  "license": "MIT",
6
6
  "author": "wangzhichao <fenglimg90@gmail.com>",
@@ -46,8 +46,8 @@
46
46
  "tree-sitter-javascript": "^0.25.0",
47
47
  "tree-sitter-typescript": "^0.23.2",
48
48
  "web-tree-sitter": "^0.26.8",
49
- "@fenglimg/fabric-server": "2.2.0-rc.9",
50
- "@fenglimg/fabric-shared": "2.2.0-rc.9"
49
+ "@fenglimg/fabric-server": "2.2.0",
50
+ "@fenglimg/fabric-shared": "2.2.0"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^22.15.0",
@@ -248,12 +248,18 @@ const ARCHIVE_NORMATIVE_KEYWORDS = [
248
248
  // (D6): a workspace that crossed the edit threshold but produced no high-value
249
249
  // signal stays quiet. watermarkTs null (never archived) → treat all events as
250
250
  // past-watermark (a never-archived repo with any edit signal is worth nudging).
251
- function hasHighValueArchiveSignal(events, watermarkTs) {
251
+ // crack 1: optional `sessionId` scopes the probe to ONE session's events so a
252
+ // neighbour window's high-value work (past the same global watermark) cannot
253
+ // keep THIS session's archive nudge alive (or, in the backlog scan, attribute a
254
+ // neighbour's signal to a dead session). Omitted → workspace-wide (legacy).
255
+ function hasHighValueArchiveSignal(events, watermarkTs, sessionId) {
252
256
  if (!Array.isArray(events)) return false;
253
257
  const wm = typeof watermarkTs === "number" ? watermarkTs : 0;
258
+ const scoped = typeof sessionId === "string" && sessionId.length > 0;
254
259
  let latestTurn = null;
255
260
  for (const e of events) {
256
261
  if (!e || typeof e.ts !== "number" || e.ts <= wm) continue;
262
+ if (scoped && e.session_id !== sessionId) continue;
257
263
  if (typeof e.event_type === "string" && ARCHIVE_HIGH_VALUE_EVENT_TYPES.has(e.event_type)) {
258
264
  return true;
259
265
  }
@@ -505,6 +511,138 @@ function countEditsSince(projectRoot, anchorTs) {
505
511
  return count;
506
512
  }
507
513
 
514
+ // ---------------------------------------------------------------------------
515
+ // Two-lane archive strategy (crack 1 + 2).
516
+ //
517
+ // In-session lane (crack 1): the archive nudge's edit trigger counts ONLY the
518
+ // current session's `file_mutated` events since the current session's OWN
519
+ // archive watermark — a neighbour window archiving (which moves the GLOBAL
520
+ // `knowledge_proposed` anchor) must never zero THIS window's unarchived work.
521
+ // We read the event ledger (file_mutated carries session_id, written by
522
+ // post-tooluse-mutation.cjs; session_archive_attempted carries
523
+ // covered_through_ts), NOT the session-blind `.fabric/.cache/edit-counter`
524
+ // sidecar — that stays for the activity-overview DISPLAY line only.
525
+ //
526
+ // Cross-session lane (crack 2): `countBacklogSessions` is the safety net that
527
+ // replaces the old global-24h timer (which any neighbour's archive reset, so a
528
+ // low-signal "dead" session was orphaned forever). It reads events.jsonl
529
+ // directly — never the resolved-bindings snapshot (KT-PIT-0017/0019 stale
530
+ // projection class).
531
+ // ---------------------------------------------------------------------------
532
+
533
+ // rc cross-session backlog constants. ANTI_LOOP mirrors archive-scan.ts.
534
+ const ARCHIVE_BACKLOG_ANTI_LOOP_HOURS = 12;
535
+ const DEFAULT_ARCHIVE_BACKLOG_SESSION_COUNT = 2;
536
+ const DEFAULT_ARCHIVE_BACKLOG_IDLE_HOURS = 24;
537
+
538
+ // Latest session_archive_attempted.covered_through_ts for this session, else null.
539
+ function sessionArchiveWatermark(events, sessionId) {
540
+ if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
541
+ return null;
542
+ }
543
+ let wm = null;
544
+ for (const ev of events) {
545
+ if (!ev || ev.session_id !== sessionId) continue;
546
+ if (ev.event_type !== "session_archive_attempted") continue;
547
+ if (typeof ev.covered_through_ts !== "number") continue;
548
+ if (wm === null || ev.covered_through_ts > wm) wm = ev.covered_through_ts;
549
+ }
550
+ return wm;
551
+ }
552
+
553
+ // Earliest event ts carrying this session_id, else null.
554
+ function sessionFirstActivityTs(events, sessionId) {
555
+ if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
556
+ return null;
557
+ }
558
+ let first = null;
559
+ for (const ev of events) {
560
+ if (!ev || ev.session_id !== sessionId || typeof ev.ts !== "number") continue;
561
+ if (first === null || ev.ts < first) first = ev.ts;
562
+ }
563
+ return first;
564
+ }
565
+
566
+ // Per-session archive anchor: this session's own last archive watermark, else
567
+ // its first activity ts. null only when the session has zero ledger presence.
568
+ function sessionAnchorTs(events, sessionId) {
569
+ const wm = sessionArchiveWatermark(events, sessionId);
570
+ if (wm !== null) return wm;
571
+ return sessionFirstActivityTs(events, sessionId);
572
+ }
573
+
574
+ // Count this session's `file_mutated` events strictly after the anchor (anchor
575
+ // null → count all of the session's mutations). Replaces the session-blind
576
+ // countEditsSince(edit-counter) for the archive TRIGGER (crack 1).
577
+ function countSessionMutationsSince(events, sessionId, anchorTs) {
578
+ if (!Array.isArray(events) || typeof sessionId !== "string" || sessionId.length === 0) {
579
+ return 0;
580
+ }
581
+ let count = 0;
582
+ for (const ev of events) {
583
+ if (!ev || ev.session_id !== sessionId) continue;
584
+ if (ev.event_type !== "file_mutated" || typeof ev.ts !== "number") continue;
585
+ if (anchorTs === null || ev.ts > anchorTs) count += 1;
586
+ }
587
+ return count;
588
+ }
589
+
590
+ // Cross-session safety net (crack 2). Counts DEAD sessions (carry a
591
+ // `session_ended` marker OR have been idle beyond idleHours) — OTHER than the
592
+ // current one — that hold unarchived high-value work and are NOT
593
+ // `user_dismissed` / inside the 12h anti-loop cooldown. This is the per-session
594
+ // replacement for the global-24h archive timer: it is NOT reset by any
595
+ // neighbour's archive, so a low-signal session that simply ended is no longer
596
+ // orphaned. Mirrors archive-scan.ts's outcome-filter semantics.
597
+ function countBacklogSessions(events, nowMs, currentSessionId, idleHours) {
598
+ if (!Array.isArray(events)) return 0;
599
+ const idleMs =
600
+ (typeof idleHours === "number" && idleHours > 0 ? idleHours : DEFAULT_ARCHIVE_BACKLOG_IDLE_HOURS) *
601
+ MS_PER_HOUR;
602
+ const lastActivity = new Map(); // sid -> max ts
603
+ const ended = new Set(); // sid with a session_ended marker
604
+ const lastAttempt = new Map(); // sid -> latest session_archive_attempted event
605
+ const sessions = new Set();
606
+ for (const ev of events) {
607
+ if (!ev || typeof ev.session_id !== "string" || ev.session_id.length === 0) continue;
608
+ const sid = ev.session_id;
609
+ sessions.add(sid);
610
+ if (typeof ev.ts === "number") {
611
+ const prev = lastActivity.get(sid);
612
+ if (prev === undefined || ev.ts > prev) lastActivity.set(sid, ev.ts);
613
+ }
614
+ if (ev.event_type === "session_ended") ended.add(sid);
615
+ if (ev.event_type === "session_archive_attempted" && typeof ev.ts === "number") {
616
+ const prior = lastAttempt.get(sid);
617
+ if (!prior || (typeof prior.ts === "number" && ev.ts > prior.ts)) lastAttempt.set(sid, ev);
618
+ }
619
+ }
620
+ let count = 0;
621
+ for (const sid of sessions) {
622
+ if (sid === currentSessionId) continue; // live lane handles the current session
623
+ const last = lastActivity.get(sid);
624
+ const isDead = ended.has(sid) || (typeof last === "number" && nowMs - last >= idleMs);
625
+ if (!isDead) continue;
626
+ const attempt = lastAttempt.get(sid);
627
+ if (attempt && attempt.outcome === "user_dismissed") continue; // respect dismissal
628
+ if (
629
+ attempt &&
630
+ typeof attempt.ts === "number" &&
631
+ nowMs - attempt.ts < ARCHIVE_BACKLOG_ANTI_LOOP_HOURS * MS_PER_HOUR
632
+ ) {
633
+ continue; // inside anti-loop cooldown
634
+ }
635
+ // Probe high-value work since the session's OWN archive watermark — null
636
+ // (never archived) means probe the whole session (wm→0), so a high-value
637
+ // signal that was the session's first event still counts. Using the
638
+ // first-activity anchor here would wrongly exclude it (strict `> anchor`).
639
+ const wm = sessionArchiveWatermark(events, sid);
640
+ if (!hasHighValueArchiveSignal(events, wm, sid)) continue; // no unarchived high-value work
641
+ count += 1;
642
+ }
643
+ return count;
644
+ }
645
+
508
646
  // ---------------------------------------------------------------------------
509
647
  // Observability grill (a + Q4): session-activity status breadcrumb.
510
648
  //
@@ -830,14 +968,17 @@ function readArchiveEditThreshold(projectRoot) {
830
968
  * Review wins over import because pending overflow is a sharper backlog signal
831
969
  * than a sparse corpus.
832
970
  *
833
- * The `editCounterStats` parameter is the parsed edit-counter view used by
834
- * the new Signal A edit branch:
835
- * { editsSinceLastProposed: number, threshold: number }
836
- * Defaults to { editsSinceLastProposed: 0, threshold: DEFAULT_ARCHIVE_EDIT_THRESHOLD }
837
- * when omitted preserves existing tests that don't populate it.
971
+ * The `editCounterStats` parameter is the per-session edit view (crack 1)
972
+ * computed in main() from file_mutated events:
973
+ * { editsSinceArchive: number, threshold: number, anchorPresent: boolean }
974
+ * The `backlogStats` parameter (crack 2) is the cross-session view:
975
+ * { deadSessionCount: number, threshold: number }
976
+ * Both default to a no-trigger shape when omitted (back-compat for callers
977
+ * pre-dating the two-lane split).
838
978
  *
839
979
  * Returns one of:
840
980
  * - { decision: 'block', reason, signal: 'archive', recommended_skill: 'fabric-archive' }
981
+ * - { decision: 'block', reason, signal: 'archive_backlog', recommended_skill: 'fabric-archive' }
841
982
  * - { decision: 'block', reason, signal: 'review', recommended_skill: 'fabric-review' }
842
983
  * - { decision: 'block', reason, signal: 'import', recommended_skill: 'fabric-import' }
843
984
  * - null on no trigger
@@ -847,21 +988,31 @@ function readArchiveEditThreshold(projectRoot) {
847
988
  // without touching the filesystem. Omitting the arg falls back to documented
848
989
  // defaults so existing in-process callers (tests that pre-date T7) still
849
990
  // pass without modification — they implicitly exercise the default path.
850
- function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner, importInFlight) {
991
+ function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner, importInFlight, backlogStats) {
851
992
  const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
852
993
  const stats = pendingStats || { count: 0, oldestAgeMs: null };
853
994
  const underseed =
854
995
  underseedStats || { nodeCount: 0, threshold: DEFAULT_UNDERSEED_NODE_THRESHOLD };
996
+ // crack 1: per-session edit view. `editsSinceArchive` = current session's
997
+ // file_mutated count since its own archive anchor; `anchorPresent` = the
998
+ // session has any ledger activity (the trigger gate, replacing the old
999
+ // "global knowledge_proposed exists" gate).
855
1000
  const editStats =
856
1001
  editCounterStats || {
857
- editsSinceLastProposed: 0,
1002
+ editsSinceArchive: 0,
858
1003
  threshold: DEFAULT_ARCHIVE_EDIT_THRESHOLD,
1004
+ anchorPresent: false,
1005
+ };
1006
+ // crack 2: cross-session backlog view (dead sessions with unarchived work).
1007
+ const backlog =
1008
+ backlogStats || {
1009
+ deadSessionCount: 0,
1010
+ threshold: DEFAULT_ARCHIVE_BACKLOG_SESSION_COUNT,
859
1011
  };
860
1012
  const cfg = thresholds || {};
861
- const archiveHintHours =
862
- typeof cfg.archiveHintHours === "number" && cfg.archiveHintHours > 0
863
- ? cfg.archiveHintHours
864
- : DEFAULT_ARCHIVE_HINT_HOURS;
1013
+ // crack 2: the global archive_hint_hours timer is retired (the cross-session
1014
+ // case is now the archive_backlog signal). cfg.archiveHintHours is still
1015
+ // accepted on the thresholds bag for back-compat but no longer drives Signal A.
865
1016
  const reviewHintPendingCount =
866
1017
  typeof cfg.reviewHintPendingCount === "number" && cfg.reviewHintPendingCount > 0
867
1018
  ? cfg.reviewHintPendingCount
@@ -875,10 +1026,18 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
875
1026
  // byte-identical Chinese output. main() always supplies the resolved variant.
876
1027
  const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
877
1028
 
878
- // ---- Archive signal (rc.6 TASK-022Signal A, 24h-OR-N-edits) -----------
879
- // Locate the most-recent knowledge_proposed event. If none exists, Signal A
880
- // stays silent a never-archived workspace is the import signal's domain.
881
- // Edit count without an anchor is meaningless and intentionally ignored.
1029
+ // ---- Archive signal (crack 1per-session edit count) -------------------
1030
+ // In-session lane: nudge when THIS session has accumulated >= threshold file
1031
+ // mutations since its OWN archive anchor (computed per-session in main() from
1032
+ // file_mutated events `editStats.editsSinceArchive`). The old global
1033
+ // 24h-OR-N-edits trigger is retired: the hours branch became the
1034
+ // archive_backlog signal below (crack 2), and the edit count is now
1035
+ // session-scoped so a neighbour window's archive can't zero this window's
1036
+ // work. `anchorPresent` gates the trigger (a session with zero ledger
1037
+ // activity has nothing to count).
1038
+ //
1039
+ // `lastProposedTs` / `hoursElapsed` are still derived here for the IMPORT
1040
+ // signal's "no knowledge_proposed in last 24h" guard further down.
882
1041
  let lastProposedTs = null;
883
1042
  for (let i = events.length - 1; i >= 0; i -= 1) {
884
1043
  const ev = events[i];
@@ -887,63 +1046,27 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
887
1046
  break;
888
1047
  }
889
1048
  }
890
-
891
1049
  const hoursElapsed =
892
1050
  lastProposedTs === null ? null : (nowMs - lastProposedTs) / MS_PER_HOUR;
893
1051
 
894
- const triggerByHours =
895
- hoursElapsed !== null && hoursElapsed >= archiveHintHours;
896
1052
  const triggerByEdits =
897
- lastProposedTs !== null &&
898
- editStats.editsSinceLastProposed >= editStats.threshold;
899
-
900
- // PRECEDENCE: archive wins when Signal A fires, regardless of review/import
901
- // state. The user gets the archive reminder first; other reminders wait
902
- // until after archive happens.
903
- if (triggerByHours || triggerByEdits) {
904
- // rc.7 T4: 人-first banner the first reader is the human user in the
905
- // AI client UI, Agent reads incidentally (Q-13). We DROP the prior
906
- // Agent-jussive imperative ("建议调用 fabric-archive skill ...") in
907
- // favour of a polite question framing and an honest activity overview
908
- // from the edit-counter sidecar (Q-6: the hook has zero content
909
- // awareness, only file-fire awareness — no fabricated "N candidates
910
- // detected" framing).
911
- //
912
- // The activity overview is injected by the caller (main() supplies it
913
- // via the `banner` arg) so decide() stays pure / filesystem-free for
914
- // tests. When omitted (legacy callers / tests pre-T4) the overview
915
- // line is skipped — the banner remains valid 3-or-2 lines depending
916
- // on data availability.
917
- //
918
- // Substring contract preserved for existing tests:
919
- // - "<hoursElapsed.toFixed(1)>h" (e.g. "25.0h")
920
- // - "<editCount> 次编辑"
921
- // - "阈值 <N>"
922
- // - "fabric-archive"
923
- // v2.0.0-rc.27 TASK-005 (audit §2.17): parts now assembled per-variant
924
- // via banner-i18n's archivePartsHours / archivePartsEdits so en mode
925
- // gets fully-English fragments instead of mixed-language output. zh-CN
926
- // / zh-CN-hybrid still render the original substring contract verbatim.
927
- const parts = [];
928
- if (triggerByHours) {
929
- parts.push(
930
- renderBanner("archivePartsHours", variant, {
931
- hoursFixed: hoursElapsed.toFixed(1),
932
- threshold: archiveHintHours,
933
- }),
934
- );
935
- }
936
- if (triggerByEdits) {
937
- parts.push(
938
- renderBanner("archivePartsEdits", variant, {
939
- count: editStats.editsSinceLastProposed,
940
- threshold: editStats.threshold,
941
- }),
942
- );
943
- }
944
- // rc.16 TASK-002: 5-banner i18n via lib/banner-i18n.cjs. Substring
945
- // contracts ('25.0h', '阈值 N', 'fabric-archive') preserved by the lib's
946
- // zh-CN templates — see lib header for the full contract.
1053
+ editStats.anchorPresent === true &&
1054
+ typeof editStats.editsSinceArchive === "number" &&
1055
+ editStats.editsSinceArchive >= editStats.threshold;
1056
+
1057
+ // PRECEDENCE: in-session archive wins over backlog/review/import recent
1058
+ // local work is the most actionable reminder.
1059
+ if (triggerByEdits) {
1060
+ // 人-first banner: edit-count fragment only (the hours fragment retired with
1061
+ // the global timer). Substring contracts ('次编辑', '阈值 N', 'fabric-archive')
1062
+ // preserved by banner-i18n's zh-CN templates. The activity overview line is
1063
+ // injected by main() via `banner` so decide() stays pure / filesystem-free.
1064
+ const parts = [
1065
+ renderBanner("archivePartsEdits", variant, {
1066
+ count: editStats.editsSinceArchive,
1067
+ threshold: editStats.threshold,
1068
+ }),
1069
+ ];
947
1070
  const line1 = renderBanner("archiveLine1", variant, { parts: parts.join(" / ") });
948
1071
  const activity = banner && typeof banner.activityOverview === "string"
949
1072
  ? banner.activityOverview
@@ -959,10 +1082,30 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
959
1082
  signal: "archive",
960
1083
  recommended_skill: "fabric-archive",
961
1084
  // v2.1 NEW-N-3: surface the firing sub-signal's numbers for the
962
- // hook_signal_emitted ledger row main() writes. Dual trigger (24h OR
963
- // N-edits): report the hours pair when it fired, else the edit-count pair.
964
- threshold: triggerByHours ? archiveHintHours : editStats.threshold,
965
- actual_value: triggerByHours ? hoursElapsed : editStats.editsSinceLastProposed,
1085
+ // hook_signal_emitted ledger row main() writes.
1086
+ threshold: editStats.threshold,
1087
+ actual_value: editStats.editsSinceArchive,
1088
+ };
1089
+ }
1090
+
1091
+ // ---- Archive backlog signal (crack 2 — cross-session safety net) ---------
1092
+ // Fires when N+ DEAD sessions (session_ended / idle) carry unarchived
1093
+ // high-value work — the per-session replacement for the old global-24h timer
1094
+ // (which any neighbour's archive reset, orphaning low-signal ended sessions).
1095
+ // KT-DEC-0007: a soft reminder, never a gate. Ranked AFTER in-session archive
1096
+ // but BEFORE review/import: losing knowledge from an ended session is a
1097
+ // sharper signal than a review/import backlog.
1098
+ if (backlog.threshold > 0 && backlog.deadSessionCount >= backlog.threshold) {
1099
+ const line1 = renderBanner("backlogLine1", variant, { count: backlog.deadSessionCount });
1100
+ const line2 = renderBanner("backlogCta", variant, {});
1101
+ const reason = `${line1}\n${line2}`;
1102
+ return {
1103
+ decision: "block",
1104
+ reason,
1105
+ signal: "archive_backlog",
1106
+ recommended_skill: "fabric-archive",
1107
+ threshold: backlog.threshold,
1108
+ actual_value: backlog.deadSessionCount,
966
1109
  };
967
1110
  }
968
1111
 
@@ -1121,6 +1264,23 @@ function readMaintenanceHintCooldownDays(projectRoot) {
1121
1264
  );
1122
1265
  }
1123
1266
 
1267
+ // crack 2: cross-session backlog signal thresholds.
1268
+ function readArchiveBacklogSessionCount(projectRoot) {
1269
+ return _readConfigNumber(
1270
+ projectRoot,
1271
+ "archive_backlog_session_count",
1272
+ DEFAULT_ARCHIVE_BACKLOG_SESSION_COUNT,
1273
+ );
1274
+ }
1275
+
1276
+ function readArchiveBacklogIdleHours(projectRoot) {
1277
+ return _readConfigNumber(
1278
+ projectRoot,
1279
+ "archive_backlog_idle_hours",
1280
+ DEFAULT_ARCHIVE_BACKLOG_IDLE_HOURS,
1281
+ );
1282
+ }
1283
+
1124
1284
  /**
1125
1285
  * Resolve the cooldown setting from .fabric/fabric-config.json
1126
1286
  * (archive_hint_cooldown_hours), falling back to DEFAULT_COOLDOWN_HOURS.
@@ -1231,7 +1391,7 @@ function writeShownCache(projectRoot, cache, sessionId) {
1231
1391
  // precedence model — KT-DEC-0007 anti-nag spirit).
1232
1392
  // -----------------------------------------------------------------------------
1233
1393
 
1234
- const DISMISSABLE_SIGNALS = ["archive", "review", "import", "maintenance"];
1394
+ const DISMISSABLE_SIGNALS = ["archive", "archive_backlog", "review", "import", "maintenance"];
1235
1395
 
1236
1396
  function sessionDismissFileName(sessionId) {
1237
1397
  const safe = String(sessionId || "anonymous").replace(/[^A-Za-z0-9_.-]/g, "-");
@@ -2123,24 +2283,41 @@ function main(env, stdio) {
2123
2283
  // ts to anchor the count; rather than rescanning events here, we mirror
2124
2284
  // decide()'s scan locally to keep the helper pure. The threshold comes
2125
2285
  // from fabric-config.json (archive_edit_threshold, default 20).
2286
+ // crack 1: per-session edit view. anchor = THIS session's own last archive
2287
+ // watermark (session_archive_attempted.covered_through_ts) else its first
2288
+ // ledger activity; count = THIS session's file_mutated events since anchor.
2289
+ // Reads the event ledger, NOT the session-blind edit-counter sidecar.
2126
2290
  let editCounterStats;
2127
2291
  try {
2128
- let anchorTs = null;
2129
- for (let i = events.length - 1; i >= 0; i -= 1) {
2130
- const ev = events[i];
2131
- if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
2132
- anchorTs = ev.ts;
2133
- break;
2134
- }
2135
- }
2292
+ const sid = resolveHookSessionId(stdinPayload);
2293
+ const anchorTs = sessionAnchorTs(events, sid);
2136
2294
  editCounterStats = {
2137
- editsSinceLastProposed: countEditsSince(cwd, anchorTs),
2295
+ editsSinceArchive: countSessionMutationsSince(events, sid, anchorTs),
2138
2296
  threshold: readArchiveEditThreshold(cwd),
2297
+ anchorPresent: anchorTs !== null,
2139
2298
  };
2140
2299
  } catch {
2141
2300
  editCounterStats = {
2142
- editsSinceLastProposed: 0,
2301
+ editsSinceArchive: 0,
2143
2302
  threshold: DEFAULT_ARCHIVE_EDIT_THRESHOLD,
2303
+ anchorPresent: false,
2304
+ };
2305
+ }
2306
+
2307
+ // crack 2: cross-session backlog view — count DEAD sessions (other than the
2308
+ // current one) carrying unarchived high-value work. Drives the
2309
+ // archive_backlog signal that replaces the retired global-24h timer.
2310
+ let backlogStats;
2311
+ try {
2312
+ const sid = resolveHookSessionId(stdinPayload);
2313
+ backlogStats = {
2314
+ deadSessionCount: countBacklogSessions(events, nowMs, sid, readArchiveBacklogIdleHours(cwd)),
2315
+ threshold: readArchiveBacklogSessionCount(cwd),
2316
+ };
2317
+ } catch {
2318
+ backlogStats = {
2319
+ deadSessionCount: 0,
2320
+ threshold: DEFAULT_ARCHIVE_BACKLOG_SESSION_COUNT,
2144
2321
  };
2145
2322
  }
2146
2323
 
@@ -2223,6 +2400,7 @@ function main(env, stdio) {
2223
2400
  thresholds,
2224
2401
  { activityOverview },
2225
2402
  importInFlight,
2403
+ backlogStats,
2226
2404
  );
2227
2405
 
2228
2406
  // v2.0.0-rc.7 T10: Signal D — maintenance hint. Evaluated AFTER A/B/C
@@ -2259,22 +2437,18 @@ function main(env, stdio) {
2259
2437
  return;
2260
2438
  }
2261
2439
 
2262
- // v2.2 dual-sink (Goal A / D6): VALUE-GATE the archive nudge. Signal A's
2263
- // edit/hours trigger is the CHECK cadence; the nudge only fires when a
2264
- // deterministic high-value signal accrued since the last archive (decouples
2265
- // check frequency from disturb frequency). Boundary-correct: replicates
2266
- // archive-scan's ledger probe (no semantic judgement). Other signals
2267
- // (review/import/maintenance) are unaffected.
2440
+ // v2.2 dual-sink (Goal A / D6): VALUE-GATE the in-session archive nudge. The
2441
+ // edit trigger is the CHECK cadence; the nudge only fires when a high-value
2442
+ // signal accrued since the last archive (decouples check from disturb).
2443
+ // crack 1: re-anchored PER SESSION (watermark = this session's own anchor,
2444
+ // probe scoped to this session) so a neighbour window's high-value work past
2445
+ // the same global watermark can't keep — or suppress — THIS window's nudge.
2446
+ // archive_backlog already incorporates high-value in its count, so it is not
2447
+ // re-gated here. Other signals (review/import/maintenance) are unaffected.
2268
2448
  if (result.signal === "archive") {
2269
- let watermarkTs = null;
2270
- for (let i = events.length - 1; i >= 0; i -= 1) {
2271
- const ev = events[i];
2272
- if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
2273
- watermarkTs = ev.ts;
2274
- break;
2275
- }
2276
- }
2277
- if (!hasHighValueArchiveSignal(events, watermarkTs)) {
2449
+ const sid = resolveHookSessionId(stdinPayload);
2450
+ const watermarkTs = sessionAnchorTs(events, sid);
2451
+ if (!hasHighValueArchiveSignal(events, watermarkTs, sid)) {
2278
2452
  return; // no high-value candidate → stay quiet (D6 value-gate)
2279
2453
  }
2280
2454
  }
@@ -2385,6 +2559,14 @@ module.exports = {
2385
2559
  // for unit testing of the truth table).
2386
2560
  isImportInFlight,
2387
2561
  decide,
2562
+ // crack 1 + 2: two-lane archive strategy helpers (exported for unit testing).
2563
+ sessionArchiveWatermark,
2564
+ sessionFirstActivityTs,
2565
+ sessionAnchorTs,
2566
+ countSessionMutationsSince,
2567
+ countBacklogSessions,
2568
+ readArchiveBacklogSessionCount,
2569
+ readArchiveBacklogIdleHours,
2388
2570
  readCooldownHours,
2389
2571
  readUnderseedThreshold,
2390
2572
  readArchiveEditThreshold,
@@ -16,11 +16,11 @@
16
16
  *
17
17
  * AI sink (additionalContext) — the dynamically generated "MEMORY.md":
18
18
  * [fabric:SessionStart] <store>
19
- * ALWAYS-ACTIVE RULES (no recall needed): # guideline/model INDEX line only
20
- * [guideline] team:KT-GLD-0001 · <summary> # KT-DEC-0036: no eager body
21
- * REFERENCE (read on demand / fab_recall): # decision/pitfall/process — title + hook
19
+ * ALWAYS-ACTIVE RULES (unconditional · act on the line): # guideline/model, BROAD only
20
+ * [guideline] team:KT-GLD-0001 · <summary> # INDEX line; body on demand (KT-DEC-0036)
21
+ * REFERENCE (situational · Read when must_read_if fires): # decision/pitfall/process, BROAD
22
22
  * [decision] team:KT-DEC-0001 — <must_read_if>
23
- * … N more folded (broad index > backstop 50; run fabric-audit)
23
+ * … N more folded (broad index > backstop 50; prune via fabric-audit)
24
24
  * Load full content: fab_recall(paths), or Read <store>/knowledge/<type>/<id>--*.md
25
25
  *
26
26
  * Human sink (systemMessage) — broad-only census breadcrumb; SessionStart is
@@ -348,12 +348,22 @@ function isImportTouched(projectRoot) {
348
348
  *
349
349
  * Best-effort: any unexpected error → return false (do not nag on faults).
350
350
  */
351
- function shouldRecommendImport(projectRoot) {
351
+ function shouldRecommendImport(projectRoot, liveTotal) {
352
352
  try {
353
353
  if (readWorkspaceBindingId(projectRoot) === null) return false;
354
354
 
355
355
  const threshold = readUnderseedThreshold(projectRoot);
356
- const nodeCount = countCanonicalNodes(projectRoot);
356
+ // P0 (Goal H3 / KT-PIT-0017 + KT-PIT-0019): prefer the LIVE census total the
357
+ // HUD displays as the single count source. The census walks the read-set
358
+ // fresh every fire (never a frozen snapshot projection), so feeding it here
359
+ // makes the import nudge and the HUD agree by construction — killing the
360
+ // "HUD shows 61 entries but the nudge claims the KB is sparse" contradiction
361
+ // the stale-snapshot count produced. Fall back to the snapshot-derived count
362
+ // only when no live total is supplied (e.g. direct unit-test calls).
363
+ const nodeCount =
364
+ typeof liveTotal === "number" && Number.isFinite(liveTotal)
365
+ ? liveTotal
366
+ : countCanonicalNodes(projectRoot);
357
367
  // #3: undeterminable count (old snapshot predating knowledge_store_dirs) →
358
368
  // skip. `null < threshold` coerces to true in JS, so an explicit guard is
359
369
  // required — otherwise the stale-snapshot case would still false-fire.
@@ -383,6 +393,12 @@ function shouldRecommendImport(projectRoot) {
383
393
  // truncation summary lines) consume it as a single source of truth.
384
394
  const TRUNCATION_THRESHOLD = 12;
385
395
 
396
+ // Goal H4 action ladder — review rung: surface a single `/fabric-review` line when
397
+ // the LIVE pending backlog exceeds this. Mirrors fabric-hint.cjs's
398
+ // DEFAULT_REVIEW_HINT_PENDING_COUNT (the Stop hook's review threshold) so the two
399
+ // surfaces agree on "how much pending is too much". Strictly `> threshold`.
400
+ const REVIEW_PENDING_THRESHOLD = 10;
401
+
386
402
  // `fabric plan-context-hint` is a thin wrapper over planContext(); on a
387
403
  // well-seeded repo it returns in ~100ms. Two-second cap is defensive — any
388
404
  // pathological hang must not stall session start.
@@ -767,57 +783,126 @@ function toPluralType(type) {
767
783
  // count the (possibly sliced) entries by knowledge_type so the human banner still
768
784
  // has something to group. Production payloads always carry the unsliced census.
769
785
  function deriveCensusFromEntries(entries) {
770
- const census = { by_type: {}, by_layer: { team: 0, personal: 0, project: 0 }, dropped_other_project: 0, total: 0 };
786
+ const census = {
787
+ by_type: {},
788
+ by_layer: { team: 0, personal: 0, project: 0 },
789
+ broad_by_type: {},
790
+ narrow_total: 0,
791
+ dropped_other_project: 0,
792
+ total: 0,
793
+ };
771
794
  if (!Array.isArray(entries)) return census;
772
795
  for (const e of entries) {
773
796
  const type = e && typeof e.type === "string" ? toPluralType(e.type) : null;
774
797
  if (type === null) continue;
798
+ const isNarrow = e.relevance_scope === "narrow";
775
799
  census.by_type[type] = (census.by_type[type] || 0) + 1;
800
+ if (isNarrow) census.narrow_total += 1;
801
+ else census.broad_by_type[type] = (census.broad_by_type[type] || 0) + 1;
776
802
  census.total += 1;
777
803
  }
778
804
  return census;
779
805
  }
780
806
 
781
- // Render the human-facing grouped census (§3). `lang` is "zh-CN" | other (en).
782
- // Returns an array of lines (may be empty when the census is empty).
807
+ // Render the human-facing scope-primary status HUD (Goal H2). `lang` is
808
+ // "zh-CN" | other (en). Returns an array of lines (empty when census is empty).
809
+ //
810
+ // Shape (KT-DEC-0029 — SessionStart is scope-primary; broad is the spine that's
811
+ // injected this session, narrow surfaces contextually via the PreToolUse hint):
812
+ // ▸ [fabric] 共 N 条 · 团队X · 项目Y · 个人Z
813
+ // broad B · 本会话注入
814
+ // ├ 常驻规则 G+M guideline G · model M (KT-DEC-0027 resident tier)
815
+ // └ 情境参考 D+P+Pr decision D · pitfall P · process Pr (reference tier)
816
+ // narrow M · 编辑对应文件时浮现 (合计 only, no per-type)
817
+ // Self-consistency invariant: broad (= 常驻 + 参考) + narrow == total.
783
818
  function renderHumanCensus(census, opts) {
784
819
  const { lang } = opts || {};
785
820
  const c = census || {};
786
- const byType = c.by_type || {};
787
821
  const total = typeof c.total === "number" ? c.total : 0;
788
822
  if (total === 0 && (c.dropped_other_project || 0) === 0) return [];
789
823
  const zh = lang === "zh-CN";
790
824
 
791
- const typeCounts = (types) =>
792
- types
793
- .filter((t) => (byType[t] || 0) > 0)
794
- .map((t) => `${TYPE_SINGULAR[t] || t} ${byType[t]}`)
795
- .join(" · ");
825
+ const broadByType = c.broad_by_type || {};
826
+ const narrowTotal = typeof c.narrow_total === "number" ? c.narrow_total : 0;
827
+ // Per-tier broad counts. `broad_by_type` keys on the plural enum.
828
+ const g = broadByType.guidelines || 0;
829
+ const m = broadByType.models || 0;
830
+ const d = broadByType.decisions || 0;
831
+ const p = broadByType.pitfalls || 0;
832
+ const pr = broadByType.processes || 0;
833
+ const residentN = g + m; // 常驻规则 (always-active: guideline + model)
834
+ const referenceN = d + p + pr; // 情境参考 (decision + pitfall + process)
835
+ const broadN = residentN + referenceN;
796
836
 
797
- const lines = [];
798
- // `total` is the read-set ENTRY COUNT (not bytes) — label it as 条/entries.
799
- lines.push(`▸ [fabric] SessionStart (${total} ${zh ? "条" : total === 1 ? "entry" : "entries"})`);
800
- // W2-2/W2-3 (KT-DEC-0027/0029): the human breadcrumb shows only the
801
- // always-loaded (guideline/model) census. The on-demand (decision/pitfall/
802
- // process) count line and the dropped-other-project line are retired — the
803
- // decision/pitfall/process REFERENCE lives in the AI sink (title + must_read_if),
804
- // and SessionStart stays silent about narrow-scoped knowledge.
805
- const alwaysCounts = typeCounts(ALWAYS_TYPES);
806
- lines.push(zh ? " ─ always-loaded(AI 也收到正文)─" : " ─ always-loaded (AI also gets bodies) ─");
807
- lines.push(` ${alwaysCounts.length > 0 ? alwaysCounts : zh ? "(无)" : "(none)"}`);
808
837
  const layer = c.by_layer || {};
809
838
  const teamCount = layer.team || 0;
810
839
  const personalCount = layer.personal || 0;
811
840
  const projectCount = layer.project || 0;
812
- if (teamCount > 0 || personalCount > 0 || projectCount > 0) {
813
- const segs = [`[team] ${teamCount}`];
814
- if (projectCount > 0) segs.push(`[project] ${projectCount}`);
815
- segs.push(`[personal] ${personalCount}`);
816
- lines.push(` ${segs.join(" · ")}`);
817
- }
841
+
842
+ const lines = [];
843
+ // Header: total entry count + semantic_scope breakdown (KT-MOD-0001 三轴).
844
+ const scopeSegs = [zh ? `团队 ${teamCount}` : `team ${teamCount}`];
845
+ if (projectCount > 0) scopeSegs.push(zh ? `项目 ${projectCount}` : `project ${projectCount}`);
846
+ scopeSegs.push(zh ? `个人 ${personalCount}` : `personal ${personalCount}`);
847
+ const totalLabel = zh ? `共 ${total} 条` : `${total} ${total === 1 ? "entry" : "entries"}`;
848
+ lines.push(`▸ [fabric] ${totalLabel} · ${scopeSegs.join(" · ")}`);
849
+
850
+ // broad spine — injected this session.
851
+ lines.push(zh ? ` broad ${broadN} · 本会话注入` : ` broad ${broadN} · injected this session`);
852
+ const residentDetail = [];
853
+ if (g > 0) residentDetail.push(`guideline ${g}`);
854
+ if (m > 0) residentDetail.push(`model ${m}`);
855
+ const refDetail = [];
856
+ if (d > 0) refDetail.push(`decision ${d}`);
857
+ if (p > 0) refDetail.push(`pitfall ${p}`);
858
+ if (pr > 0) refDetail.push(`process ${pr}`);
859
+ const dash = zh ? "—" : "—";
860
+ lines.push(
861
+ zh
862
+ ? ` ├ 常驻规则 ${residentN} ${residentDetail.join(" · ") || dash}`
863
+ : ` ├ resident ${residentN} ${residentDetail.join(" · ") || dash}`,
864
+ );
865
+ lines.push(
866
+ zh
867
+ ? ` └ 情境参考 ${referenceN} ${refDetail.join(" · ") || dash}`
868
+ : ` └ reference ${referenceN} ${refDetail.join(" · ") || dash}`,
869
+ );
870
+
871
+ // narrow remainder — 合计 only (no per-type; it's file-specific, surfaces on edit).
872
+ lines.push(
873
+ zh
874
+ ? ` narrow ${narrowTotal} · 编辑对应文件时浮现`
875
+ : ` narrow ${narrowTotal} · surfaces when you edit matching files`,
876
+ );
818
877
  return lines;
819
878
  }
820
879
 
880
+ // Goal H2: the SessionStart store label, scope-primary wording — `写入 X · 只读 Y`
881
+ // (write target receives new knowledge; the rest are read-only sources). Replaces
882
+ // the legacy `read-set stores: a (write), b (ro)` jargon line inline (kept local
883
+ // to this hook so the shared lib formatStoreLabels — used by other hooks — is
884
+ // untouched). Empty string when there is nothing to show.
885
+ function renderScopeStoreLabel(snapshot, lang) {
886
+ if (!snapshot || !snapshot.read_set || !Array.isArray(snapshot.read_set.stores)) return "";
887
+ const stores = snapshot.read_set.stores;
888
+ if (stores.length === 0) return "";
889
+ const zh = lang === "zh-CN";
890
+ const writeAlias = snapshot.write_target && snapshot.write_target.alias;
891
+ const writeStores = [];
892
+ const readonlyStores = [];
893
+ for (const s of stores) {
894
+ const alias = s && typeof s.alias === "string" ? s.alias : null;
895
+ if (alias === null) continue;
896
+ if (alias === writeAlias) writeStores.push(alias);
897
+ else readonlyStores.push(alias);
898
+ }
899
+ const segs = [];
900
+ if (writeStores.length > 0) segs.push((zh ? "写入 " : "write ") + writeStores.join(", "));
901
+ if (readonlyStores.length > 0) segs.push((zh ? "只读 " : "readonly ") + readonlyStores.join(", "));
902
+ if (segs.length === 0) return "";
903
+ return " " + segs.join(" · ");
904
+ }
905
+
821
906
  // W2 (KT-DEC-0027/0028/0029): render the AI-facing sink — the dynamically
822
907
  // generated "MEMORY.md" spine injected into the SessionStart context. Two
823
908
  // type-tiered sections over the BROAD knowledge (narrow stays silent — D0029):
@@ -857,7 +942,11 @@ function renderAiSink(opts) {
857
942
  lines.push(`[fabric:SessionStart] ${storeLabel || "store"}`);
858
943
 
859
944
  // ALWAYS-ACTIVE RULES — index-only (title + summary), never the eager body.
860
- lines.push(zh ? "ALWAYS-ACTIVE RULES (无需再 recall):" : "ALWAYS-ACTIVE RULES (no recall needed):");
945
+ lines.push(
946
+ zh
947
+ ? "ALWAYS-ACTIVE RULES (无条件适用 · 照此行遵循,正文按需取):"
948
+ : "ALWAYS-ACTIVE RULES (unconditional · act on the line; body on demand):",
949
+ );
861
950
  if (bodies.length === 0) {
862
951
  lines.push(zh ? " (无 always-active 条目)" : " (none)");
863
952
  } else {
@@ -875,7 +964,11 @@ function renderAiSink(opts) {
875
964
 
876
965
  // REFERENCE — broad decision/pitfall/process: title + must_read_if hook.
877
966
  if (referenceEntries.length > 0) {
878
- lines.push(zh ? "REFERENCE (按需 Read / fab_recall):" : "REFERENCE (read on demand / fab_recall):");
967
+ lines.push(
968
+ zh
969
+ ? "REFERENCE (情境触发 · 命中 must_read_if 时 Read / fab_recall):"
970
+ : "REFERENCE (situational · Read when must_read_if fires / fab_recall):",
971
+ );
879
972
  let folded = 0;
880
973
  for (const e of referenceEntries) {
881
974
  if (backstop > 0 && indexCount >= backstop) {
@@ -897,8 +990,8 @@ function renderAiSink(opts) {
897
990
  if (folded > 0) {
898
991
  lines.push(
899
992
  zh
900
- ? ` … 另 ${folded} 条 broad 条目折叠 (broad index > backstop ${backstop}; fabric-audit)`
901
- : ` … ${folded} more broad entr${folded === 1 ? "y" : "ies"} folded (broad index > backstop ${backstop}; run fabric-audit)`,
993
+ ? ` … 另 ${folded} 条 broad 条目折叠 (broad index > backstop ${backstop})。先跑 fabric-audit 瘦身;确需全展示再调 .fabric/fabric-config.json#broad_index_backstop (20..500)`
994
+ : ` … ${folded} more broad entr${folded === 1 ? "y" : "ies"} folded (broad index > backstop ${backstop}). Run fabric-audit to prune first; raise .fabric/fabric-config.json#broad_index_backstop (20..500) only if you truly need them all`,
902
995
  );
903
996
  }
904
997
  }
@@ -909,6 +1002,15 @@ function renderAiSink(opts) {
909
1002
  ? "取正文: fab_recall(paths), 或 Read <store>/knowledge/<type>/<id>--*.md"
910
1003
  : "Load full content: fab_recall(paths), or Read <store>/knowledge/<type>/<id>--*.md",
911
1004
  );
1005
+ // H6 scope discipline: this sink carries ONLY broad (always-relevant) knowledge;
1006
+ // narrow (file-specific) entries surface contextually via the PreToolUse hint
1007
+ // when you edit a matching file (KT-DEC-0029). Stops the agent from assuming
1008
+ // SessionStart is the whole KB.
1009
+ lines.push(
1010
+ zh
1011
+ ? "范围: 此处仅 broad(始终相关);narrow(文件专属)在你编辑对应文件时由 PreToolUse 浮现"
1012
+ : "Scope: broad only (always relevant) here; narrow (file-specific) surfaces via the PreToolUse hint when you edit a matching file",
1013
+ );
912
1014
  return lines.join("\n");
913
1015
  }
914
1016
 
@@ -939,7 +1041,6 @@ function buildSessionStartSinks(cwd, payload, env) {
939
1041
  // review-time cold-eval audit pass.
940
1042
  const resolvedPayload = payload;
941
1043
 
942
- const recommendImport = shouldRecommendImport(cwd);
943
1044
  const summaryMaxLen = readSummaryMaxLen(cwd);
944
1045
  const fabricLanguageForEmit = readFabricLanguage(cwd);
945
1046
 
@@ -956,6 +1057,24 @@ function buildSessionStartSinks(cwd, payload, env) {
956
1057
  ? payload.always_bodies
957
1058
  : [];
958
1059
 
1060
+ // H3: the LIVE census total is the single count source for the import gate —
1061
+ // computed AFTER census so the nudge and the HUD agree by construction.
1062
+ const censusTotal = census && typeof census.total === "number" ? census.total : undefined;
1063
+ const recommendImport = shouldRecommendImport(cwd, censusTotal);
1064
+
1065
+ // Read the resolved-bindings snapshot ONCE — reused for the scope store label
1066
+ // (写入/只读) and the H4 review-rung pending count. Best-effort/decorative: any
1067
+ // failure leaves snapshot null and the dependent lines simply don't render.
1068
+ let snapshot = null;
1069
+ if (bindingsSnapshotReader !== null) {
1070
+ try {
1071
+ const bindingId = readWorkspaceBindingId(cwd);
1072
+ if (bindingId) snapshot = bindingsSnapshotReader.readBindingsSnapshot(bindingId);
1073
+ } catch {
1074
+ snapshot = null;
1075
+ }
1076
+ }
1077
+
959
1078
  const humanGate =
960
1079
  nudgePolicy !== null
961
1080
  ? nudgePolicy.resolveHumanSink(cwd, "session_start", {})
@@ -967,33 +1086,46 @@ function buildSessionStartSinks(cwd, payload, env) {
967
1086
  const detail = renderSummary(resolvedPayload, summaryMaxLen);
968
1087
  humanLines.push(...detail);
969
1088
  }
970
- if (bindingsSnapshotReader !== null && humanLines.length > 0) {
971
- try {
972
- const bindingId = readWorkspaceBindingId(cwd);
973
- if (bindingId) {
974
- const label = bindingsSnapshotReader.formatStoreLabels(
975
- bindingsSnapshotReader.readBindingsSnapshot(bindingId),
1089
+ // H2: scope store label `写入 X · 只读 Y` (replaces the legacy read-set jargon).
1090
+ if (humanLines.length > 0 && snapshot !== null) {
1091
+ const storeLabel = renderScopeStoreLabel(snapshot, fabricLanguageForEmit);
1092
+ if (storeLabel) humanLines.push(storeLabel);
1093
+ }
1094
+
1095
+ // H4 action ladder (KT-DEC-0007: nudge, never a gate). AT MOST ONE line, the
1096
+ // highest-priority rung wins, and steady state is fully silent:
1097
+ // 1. import — KB is sparse (recommendImport, off the live census total)
1098
+ // 2. review — pending backlog exceeds REVIEW_PENDING_THRESHOLD (live count)
1099
+ // 3. (silent)
1100
+ if (humanLines.length > 0 && fabricLanguageForEmit !== null) {
1101
+ if (recommendImport) {
1102
+ humanLines.push(renderBanner("broadImportBanner", fabricLanguageForEmit, {}));
1103
+ } else if (snapshot !== null && bindingsSnapshotReader !== null) {
1104
+ let pendingCount = 0;
1105
+ try {
1106
+ const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
1107
+ if (live && Number.isFinite(live.pendingCount)) pendingCount = Math.floor(live.pendingCount);
1108
+ } catch {
1109
+ pendingCount = 0;
1110
+ }
1111
+ if (pendingCount > REVIEW_PENDING_THRESHOLD) {
1112
+ humanLines.push(
1113
+ fabricLanguageForEmit === "zh-CN"
1114
+ ? ` 📋 Fabric: ${pendingCount} 条 pending 待审,是否调 /fabric-review?`
1115
+ : ` 📋 Fabric: ${pendingCount} pending entries — run /fabric-review?`,
976
1116
  );
977
- if (label) humanLines.push(label);
978
1117
  }
979
- } catch {
980
- // store labels are decorative provenance — never crash the hook
981
1118
  }
982
1119
  }
983
- if (recommendImport && humanLines.length > 0 && fabricLanguageForEmit !== null) {
984
- humanLines.push(renderBanner("broadImportBanner", fabricLanguageForEmit, {}));
985
- }
1120
+
1121
+ // H5: the `下一步: …fab_recall…` AI-plumbing line is retired from the human sink
1122
+ // (the AI gets it from its own footer + the MCP server directive). Keep only the
1123
+ // pointer to the byte-identical inspector for this injection.
986
1124
  if (humanLines.length > 0) {
987
1125
  humanLines.push(
988
1126
  fabricLanguageForEmit === "zh-CN"
989
- ? "下一步: 改相关文件前调 fab_recall(paths) 拿 KB 条目的描述+读路径;按需 Read 路径取正文。"
990
- : "Next: before editing related files, call fab_recall(paths) for the KB entries' descriptions + read paths; Read a path on demand for the body.",
991
- );
992
- // Block 5 (Option X): point to the byte-identical inspector for this injection.
993
- humanLines.push(
994
- fabricLanguageForEmit === "zh-CN"
995
- ? "看具体注入: fabric context (--explain 看每条来源)"
996
- : "Inspect this injection: fabric context (--explain for per-entry provenance)",
1127
+ ? " 看具体注入: fabric context (--explain 看每条来源)"
1128
+ : " Inspect this injection: fabric context (--explain for per-entry provenance)",
997
1129
  );
998
1130
  }
999
1131
 
@@ -28,8 +28,9 @@
28
28
  *
29
29
  * - STRINGS — exported for test introspection only (read-only by convention).
30
30
  *
31
- * Banner keys (11 total):
31
+ * Banner keys:
32
32
  * Signal A (archive): archiveLine1, archiveActivity, archiveCta
33
+ * Archive backlog: backlogLine1, backlogCta
33
34
  * Signal B (review): reviewLine1, reviewCta
34
35
  * Signal C (import): importLine1, importCta
35
36
  * Signal D (maintenance): maintenanceLine1Never, maintenanceLine1Aged, maintenanceLine2
@@ -165,6 +166,23 @@ const STRINGS = {
165
166
  "zh-CN-hybrid": () => " 是否调 /fabric-archive 检查值得归档的决策/踩坑/复用?",
166
167
  },
167
168
 
169
+ // ---- Archive backlog (cross-session safety net, crack 2) ------------------
170
+ // Replaces the old global-24h archive timer: counts DEAD sessions (session
171
+ // ended / idle) carrying unarchived high-value work. Substring "${count}" is
172
+ // addressable for tests. params: { count: number }
173
+ backlogLine1: {
174
+ "zh-CN": (p) => `📋 Fabric: ${p.count} 个已结束的会话有未归档的高价值改动。`,
175
+ en: (p) => `📋 Fabric: ${p.count} ended session(s) carry unarchived high-value work.`,
176
+ "zh-CN-hybrid": (p) => `📋 Fabric: ${p.count} 个已结束的会话有未归档的高价值改动。`,
177
+ },
178
+
179
+ // params: {} — protected token /fabric-archive verbatim across all variants.
180
+ backlogCta: {
181
+ "zh-CN": () => " 是否调 /fabric-archive 跨会话补归档这些遗漏?",
182
+ en: () => " Run /fabric-archive to sweep these missed sessions across the backlog?",
183
+ "zh-CN-hybrid": () => " 是否调 /fabric-archive 跨会话补归档这些遗漏?",
184
+ },
185
+
168
186
  // ---- Signal B: review -----------------------------------------------------
169
187
  // Source (zh-CN): fabric-hint.cjs:651 `📋 Fabric: 已积累 ${stats.count} 条待审核知识${ageSuffix}。`
170
188
  // params: { count, ageSuffix } — ageSuffix is " / 最早一条 N.N 天前" or "" (zh-CN only)
@@ -65,6 +65,18 @@ The deterministic ledger scan now runs **server-side** — call `fab_archive_sca
65
65
 
66
66
  Then (LLM side, Boundary B): for each returned `session_id`, load `.fabric/.cache/session-digests/<session_id>.md`, concatenate into a `### Cross-session digest` block, and populate `source_sessions[]` + `session_context` for Phase 4. Cap at `archive_digest_max_sessions`. Missing digest files degrade silently.
67
67
 
68
+ **Coverage transparency (crack 3 — cheap recall backstop).** BEFORE collecting candidates, surface the scan's watermark + drops to the user so a human can act as the recall detector and manually override (`--range <session_id>` to force a dropped session back in). This is the affordable substitute for the (deferred) periodic cold-eval miss-rate audit — show, don't hide, what the deterministic filter skipped:
69
+
70
+ ```
71
+ 📋 归档覆盖到 <covered_through_ts 转人类可读时间>。
72
+ 纳入会话: <session_ids.length> 个。
73
+ 跳过 <dropped.length> 个: <每个 {session_id 短码} (reason)>
74
+ reason 含义: user_dismissed=用户曾拒绝 / cooldown=12h 防抖内 / no_new_signal=自上次归档无新高价值活
75
+ 若某个被跳过的会话其实有该归档的内容,显式 `fabric-archive --range <session_id>` 强制纳入。
76
+ ```
77
+
78
+ Render `dropped` only when non-empty; render the watermark line always. en variant mirrors the same fields. Keep it ONE compact block — this is a backstop affordance, not a report.
79
+
68
80
  `Read ref/phase-1-cross-session.md` for the filter state machine + digest-stitch + graceful-degradation notes. The hand-rolled `tail -n 200` scan is retired — `fab_archive_scan` is the source of truth.
69
81
 
70
82
  Graceful degradation: missing digest cache → single-session fallback. Missing `session_archive_attempted` events (pre-rc.25) → legacy "scan everything since anchor" behaviour.
@@ -93,6 +105,8 @@ Pre-PASS HARD gate (rc.37 NEW-4): per candidate, run `fab_review action="search"
93
105
 
94
106
  For each candidate, propose **type** ∈ {model, decision, guideline, pitfall, process}, **layer** ∈ {team, personal} via the verbatim heuristic below, **slug** (kebab-case 2-5 words, 20-40 chars, unique within type+layer bucket), **summary** (1-2 sentences).
95
107
 
108
+ > **Self-sufficiency standard — guideline / model summaries (KT-GLD-0001/0006).** These two types land in the SessionStart **ALWAYS-ACTIVE** sink as a single INDEX line with NO body injected — so the summary IS the operative rule the agent acts on. Author it as a self-contained imperative that states the thesis (the *what* + the operative *so-what*), e.g. `改源码前先读 bootstrap+compiler config;scripts 为 init 主执行边界`. A topic label that only points at the body (`Code style guidelines`, `Scope model`) is NOT acceptable here — the reader can't act on it without a fetch, breaking the always-active contract. decision/pitfall/process summaries are exempt (they surface as `must_read_if` triggers, deliberately pointers). Do NOT self-judge sufficiency in this phase (curse-of-knowledge rubber-stamps — KT-GLD-0006); authoring to the standard is the write-time floor, the zero-context cold-eval at review time is the real gate.
109
+
96
110
  #### Layer Classification Heuristic (verbatim, contract-locked)
97
111
 
98
112
  > - **强 team**: 引用本项目代码、团队共识用语("we decided")、fabric-import 路径产物、业务领域、绑定本项目代码的 pitfall
@@ -119,6 +119,15 @@ DO NOT AskUserQuestion "is this a duplicate?" — LLM already judged. User only
119
119
 
120
120
  `Read ref/semantic-check.md` for full procedure + 三类判断的细化定义.
121
121
 
122
+ ## Summary Self-Sufficiency Gate (guideline / model only — KT-GLD-0006)
123
+
124
+ Guideline/model entries surface in the SessionStart **ALWAYS-ACTIVE** sink as a body-less INDEX line, so an opaque summary (`Code style guidelines`) leaks in as an unactionable "rule". Before approving/promoting a **guideline or model** (only these two types — decision/pitfall/process surface as `must_read_if` triggers and are exempt), run the summary through the **zero-context cold-eval judge**, never your own judgment:
125
+
126
+ - The reviewing agent self-judging sufficiency is curse-of-knowledge — it back-fills from context it already has and rubber-stamps pointers (KT-GLD-0006). The withheld-body cold eval is the whole point.
127
+ - Build the batch with `summary-cold-eval.ts#buildColdEvalBatch` (rubric = `COLD_EVAL_RUBRIC`, candidates = the guideline/model summaries) and hand it to an **offline** judge via `maestro delegate` (zero-context, batched — NOT on the hot path). The judge returns `ColdEvalVerdict[]`.
128
+ - For each `self_sufficient=false` verdict: surface `⚠ Summary not act-on-able (cold-eval); suggested: <suggested_summary>` and route to `modify-content` (summary rewrite, stable_id preserved) — do NOT approve as-is. `self_sufficient=true` → no action.
129
+ - This is a nudge, not a hard block (KT-DEC-0007): the user may still approve over a failed verdict, but the flag must be shown.
130
+
122
131
  ## Narrowing Imported Entries & Modify Sub-Flow
123
132
 
124
133
  `modify` is the only action that mutates frontmatter or stable_id. Two paths: