@fenglimg/fabric-cli 2.2.0-rc.8 → 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.
@@ -117,6 +117,10 @@ function readSnapshotKnowledgeStats(projectRoot, now) {
117
117
  }
118
118
  try {
119
119
  const snapshot = bindingsSnapshotReader.readBindingsSnapshot(bindingId);
120
+ // No snapshot file → empty corpus (KT-DEC-0007), preserving prior behavior.
121
+ if (!snapshot) {
122
+ return empty;
123
+ }
120
124
  // LIVE recount off the snapshot's resolved store dirs. The cached
121
125
  // knowledge_stats projection is frozen at snapshot-write time, so once the
122
126
  // pending queue is reviewed (or store content syncs out-of-band) it goes
@@ -124,8 +128,16 @@ function readSnapshotKnowledgeStats(projectRoot, now) {
124
128
  // (KT-PIT-0017). The authoritative count is the live *.md walk under the
125
129
  // resolved store dirs.
126
130
  const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
127
- if (!live || typeof live !== "object") {
128
- return empty;
131
+ // #3: a snapshot predating knowledge_store_dirs makes liveKnowledgeStats
132
+ // return null — counts are undeterminable and the cached projection is
133
+ // unreliable. Return `undefined` (a marker distinct from the `null` that
134
+ // lib/binding-absent returns, which readPendingStats uses as its legacy-
135
+ // fallback signal) so countCanonicalNodes maps it to "unknown" and the
136
+ // underseed signal SKIPS rather than false-firing on a stale corpus (snapshot
137
+ // self-heals on the next install/sync). Distinguished from the missing-
138
+ // snapshot case above, which stays `empty` (genuine fresh-project zero).
139
+ if (live === null) {
140
+ return undefined;
129
141
  }
130
142
  const pendingCount =
131
143
  Number.isFinite(live.pendingCount) && live.pendingCount > 0 ? Math.floor(live.pendingCount) : 0;
@@ -236,12 +248,18 @@ const ARCHIVE_NORMATIVE_KEYWORDS = [
236
248
  // (D6): a workspace that crossed the edit threshold but produced no high-value
237
249
  // signal stays quiet. watermarkTs null (never archived) → treat all events as
238
250
  // past-watermark (a never-archived repo with any edit signal is worth nudging).
239
- 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) {
240
256
  if (!Array.isArray(events)) return false;
241
257
  const wm = typeof watermarkTs === "number" ? watermarkTs : 0;
258
+ const scoped = typeof sessionId === "string" && sessionId.length > 0;
242
259
  let latestTurn = null;
243
260
  for (const e of events) {
244
261
  if (!e || typeof e.ts !== "number" || e.ts <= wm) continue;
262
+ if (scoped && e.session_id !== sessionId) continue;
245
263
  if (typeof e.event_type === "string" && ARCHIVE_HIGH_VALUE_EVENT_TYPES.has(e.event_type)) {
246
264
  return true;
247
265
  }
@@ -412,7 +430,10 @@ function readLedger(projectRoot) {
412
430
  */
413
431
  function readPendingStats(projectRoot, now) {
414
432
  const stats = readSnapshotKnowledgeStats(projectRoot, now);
415
- if (stats !== null) {
433
+ // `!= null` (loose) also catches the `undefined` old-snapshot marker (#3)
434
+ // fall through to the legacy reader (which degrades to 0 → no phantom review
435
+ // nudge), rather than dereferencing pendingCount on a non-object.
436
+ if (stats != null) {
416
437
  return { count: stats.pendingCount, oldestAgeMs: stats.oldestPendingAgeMs };
417
438
  }
418
439
  return readLegacyPendingStats(projectRoot, now);
@@ -425,6 +446,12 @@ function readPendingStats(projectRoot, now) {
425
446
  */
426
447
  function countCanonicalNodes(projectRoot) {
427
448
  const stats = readSnapshotKnowledgeStats(projectRoot);
449
+ // #3: `undefined` = snapshot EXISTS but predates knowledge_store_dirs →
450
+ // undeterminable → return null so decide()'s underseed signal SKIPS rather than
451
+ // false-firing on a stale corpus. `null` = no reader / not bound → degrade to 0
452
+ // (KT-DEC-0007, preserved). The `empty` object (missing snapshot) → canonical 0,
453
+ // still firing correctly for a genuinely fresh corpus.
454
+ if (stats === undefined) return null;
428
455
  return stats === null ? 0 : stats.canonicalCount;
429
456
  }
430
457
 
@@ -484,6 +511,138 @@ function countEditsSince(projectRoot, anchorTs) {
484
511
  return count;
485
512
  }
486
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
+
487
646
  // ---------------------------------------------------------------------------
488
647
  // Observability grill (a + Q4): session-activity status breadcrumb.
489
648
  //
@@ -809,14 +968,17 @@ function readArchiveEditThreshold(projectRoot) {
809
968
  * Review wins over import because pending overflow is a sharper backlog signal
810
969
  * than a sparse corpus.
811
970
  *
812
- * The `editCounterStats` parameter is the parsed edit-counter view used by
813
- * the new Signal A edit branch:
814
- * { editsSinceLastProposed: number, threshold: number }
815
- * Defaults to { editsSinceLastProposed: 0, threshold: DEFAULT_ARCHIVE_EDIT_THRESHOLD }
816
- * 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).
817
978
  *
818
979
  * Returns one of:
819
980
  * - { decision: 'block', reason, signal: 'archive', recommended_skill: 'fabric-archive' }
981
+ * - { decision: 'block', reason, signal: 'archive_backlog', recommended_skill: 'fabric-archive' }
820
982
  * - { decision: 'block', reason, signal: 'review', recommended_skill: 'fabric-review' }
821
983
  * - { decision: 'block', reason, signal: 'import', recommended_skill: 'fabric-import' }
822
984
  * - null on no trigger
@@ -826,21 +988,31 @@ function readArchiveEditThreshold(projectRoot) {
826
988
  // without touching the filesystem. Omitting the arg falls back to documented
827
989
  // defaults so existing in-process callers (tests that pre-date T7) still
828
990
  // pass without modification — they implicitly exercise the default path.
829
- function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner, importInFlight) {
991
+ function decide(events, now, pendingStats, underseedStats, editCounterStats, thresholds, banner, importInFlight, backlogStats) {
830
992
  const nowMs = now instanceof Date ? now.getTime() : Number(now) || Date.now();
831
993
  const stats = pendingStats || { count: 0, oldestAgeMs: null };
832
994
  const underseed =
833
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).
834
1000
  const editStats =
835
1001
  editCounterStats || {
836
- editsSinceLastProposed: 0,
1002
+ editsSinceArchive: 0,
837
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,
838
1011
  };
839
1012
  const cfg = thresholds || {};
840
- const archiveHintHours =
841
- typeof cfg.archiveHintHours === "number" && cfg.archiveHintHours > 0
842
- ? cfg.archiveHintHours
843
- : 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.
844
1016
  const reviewHintPendingCount =
845
1017
  typeof cfg.reviewHintPendingCount === "number" && cfg.reviewHintPendingCount > 0
846
1018
  ? cfg.reviewHintPendingCount
@@ -854,10 +1026,18 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
854
1026
  // byte-identical Chinese output. main() always supplies the resolved variant.
855
1027
  const variant = typeof cfg.variant === "string" ? cfg.variant : "zh-CN";
856
1028
 
857
- // ---- Archive signal (rc.6 TASK-022Signal A, 24h-OR-N-edits) -----------
858
- // Locate the most-recent knowledge_proposed event. If none exists, Signal A
859
- // stays silent a never-archived workspace is the import signal's domain.
860
- // 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.
861
1041
  let lastProposedTs = null;
862
1042
  for (let i = events.length - 1; i >= 0; i -= 1) {
863
1043
  const ev = events[i];
@@ -866,63 +1046,27 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
866
1046
  break;
867
1047
  }
868
1048
  }
869
-
870
1049
  const hoursElapsed =
871
1050
  lastProposedTs === null ? null : (nowMs - lastProposedTs) / MS_PER_HOUR;
872
1051
 
873
- const triggerByHours =
874
- hoursElapsed !== null && hoursElapsed >= archiveHintHours;
875
1052
  const triggerByEdits =
876
- lastProposedTs !== null &&
877
- editStats.editsSinceLastProposed >= editStats.threshold;
878
-
879
- // PRECEDENCE: archive wins when Signal A fires, regardless of review/import
880
- // state. The user gets the archive reminder first; other reminders wait
881
- // until after archive happens.
882
- if (triggerByHours || triggerByEdits) {
883
- // rc.7 T4: 人-first banner the first reader is the human user in the
884
- // AI client UI, Agent reads incidentally (Q-13). We DROP the prior
885
- // Agent-jussive imperative ("建议调用 fabric-archive skill ...") in
886
- // favour of a polite question framing and an honest activity overview
887
- // from the edit-counter sidecar (Q-6: the hook has zero content
888
- // awareness, only file-fire awareness — no fabricated "N candidates
889
- // detected" framing).
890
- //
891
- // The activity overview is injected by the caller (main() supplies it
892
- // via the `banner` arg) so decide() stays pure / filesystem-free for
893
- // tests. When omitted (legacy callers / tests pre-T4) the overview
894
- // line is skipped — the banner remains valid 3-or-2 lines depending
895
- // on data availability.
896
- //
897
- // Substring contract preserved for existing tests:
898
- // - "<hoursElapsed.toFixed(1)>h" (e.g. "25.0h")
899
- // - "<editCount> 次编辑"
900
- // - "阈值 <N>"
901
- // - "fabric-archive"
902
- // v2.0.0-rc.27 TASK-005 (audit §2.17): parts now assembled per-variant
903
- // via banner-i18n's archivePartsHours / archivePartsEdits so en mode
904
- // gets fully-English fragments instead of mixed-language output. zh-CN
905
- // / zh-CN-hybrid still render the original substring contract verbatim.
906
- const parts = [];
907
- if (triggerByHours) {
908
- parts.push(
909
- renderBanner("archivePartsHours", variant, {
910
- hoursFixed: hoursElapsed.toFixed(1),
911
- threshold: archiveHintHours,
912
- }),
913
- );
914
- }
915
- if (triggerByEdits) {
916
- parts.push(
917
- renderBanner("archivePartsEdits", variant, {
918
- count: editStats.editsSinceLastProposed,
919
- threshold: editStats.threshold,
920
- }),
921
- );
922
- }
923
- // rc.16 TASK-002: 5-banner i18n via lib/banner-i18n.cjs. Substring
924
- // contracts ('25.0h', '阈值 N', 'fabric-archive') preserved by the lib's
925
- // 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
+ ];
926
1070
  const line1 = renderBanner("archiveLine1", variant, { parts: parts.join(" / ") });
927
1071
  const activity = banner && typeof banner.activityOverview === "string"
928
1072
  ? banner.activityOverview
@@ -938,10 +1082,30 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
938
1082
  signal: "archive",
939
1083
  recommended_skill: "fabric-archive",
940
1084
  // v2.1 NEW-N-3: surface the firing sub-signal's numbers for the
941
- // hook_signal_emitted ledger row main() writes. Dual trigger (24h OR
942
- // N-edits): report the hours pair when it fired, else the edit-count pair.
943
- threshold: triggerByHours ? archiveHintHours : editStats.threshold,
944
- 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,
945
1109
  };
946
1110
  }
947
1111
 
@@ -1010,6 +1174,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
1010
1174
  lastInitScanTs === null ? null : (nowMs - lastInitScanTs) / MS_PER_HOUR;
1011
1175
  const hoursSinceProposed = hoursElapsed; // reuse archive-signal calc above
1012
1176
  const triggerUnderseed =
1177
+ // #3: null = undeterminable canonical count (old snapshot) → skip. Guard
1178
+ // first because `null < threshold` coerces to true in JS and would else
1179
+ // false-fire the underseed nudge on a stale corpus.
1180
+ underseed.nodeCount != null &&
1013
1181
  underseed.nodeCount < underseed.threshold &&
1014
1182
  hoursSinceInit !== null &&
1015
1183
  hoursSinceInit >= UNDERSEED_POST_INIT_QUIET_HOURS &&
@@ -1096,6 +1264,23 @@ function readMaintenanceHintCooldownDays(projectRoot) {
1096
1264
  );
1097
1265
  }
1098
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
+
1099
1284
  /**
1100
1285
  * Resolve the cooldown setting from .fabric/fabric-config.json
1101
1286
  * (archive_hint_cooldown_hours), falling back to DEFAULT_COOLDOWN_HOURS.
@@ -1206,7 +1391,7 @@ function writeShownCache(projectRoot, cache, sessionId) {
1206
1391
  // precedence model — KT-DEC-0007 anti-nag spirit).
1207
1392
  // -----------------------------------------------------------------------------
1208
1393
 
1209
- const DISMISSABLE_SIGNALS = ["archive", "review", "import", "maintenance"];
1394
+ const DISMISSABLE_SIGNALS = ["archive", "archive_backlog", "review", "import", "maintenance"];
1210
1395
 
1211
1396
  function sessionDismissFileName(sessionId) {
1212
1397
  const safe = String(sessionId || "anonymous").replace(/[^A-Za-z0-9_.-]/g, "-");
@@ -2098,24 +2283,41 @@ function main(env, stdio) {
2098
2283
  // ts to anchor the count; rather than rescanning events here, we mirror
2099
2284
  // decide()'s scan locally to keep the helper pure. The threshold comes
2100
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.
2101
2290
  let editCounterStats;
2102
2291
  try {
2103
- let anchorTs = null;
2104
- for (let i = events.length - 1; i >= 0; i -= 1) {
2105
- const ev = events[i];
2106
- if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
2107
- anchorTs = ev.ts;
2108
- break;
2109
- }
2110
- }
2292
+ const sid = resolveHookSessionId(stdinPayload);
2293
+ const anchorTs = sessionAnchorTs(events, sid);
2111
2294
  editCounterStats = {
2112
- editsSinceLastProposed: countEditsSince(cwd, anchorTs),
2295
+ editsSinceArchive: countSessionMutationsSince(events, sid, anchorTs),
2113
2296
  threshold: readArchiveEditThreshold(cwd),
2297
+ anchorPresent: anchorTs !== null,
2114
2298
  };
2115
2299
  } catch {
2116
2300
  editCounterStats = {
2117
- editsSinceLastProposed: 0,
2301
+ editsSinceArchive: 0,
2118
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,
2119
2321
  };
2120
2322
  }
2121
2323
 
@@ -2198,6 +2400,7 @@ function main(env, stdio) {
2198
2400
  thresholds,
2199
2401
  { activityOverview },
2200
2402
  importInFlight,
2403
+ backlogStats,
2201
2404
  );
2202
2405
 
2203
2406
  // v2.0.0-rc.7 T10: Signal D — maintenance hint. Evaluated AFTER A/B/C
@@ -2234,22 +2437,18 @@ function main(env, stdio) {
2234
2437
  return;
2235
2438
  }
2236
2439
 
2237
- // v2.2 dual-sink (Goal A / D6): VALUE-GATE the archive nudge. Signal A's
2238
- // edit/hours trigger is the CHECK cadence; the nudge only fires when a
2239
- // deterministic high-value signal accrued since the last archive (decouples
2240
- // check frequency from disturb frequency). Boundary-correct: replicates
2241
- // archive-scan's ledger probe (no semantic judgement). Other signals
2242
- // (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.
2243
2448
  if (result.signal === "archive") {
2244
- let watermarkTs = null;
2245
- for (let i = events.length - 1; i >= 0; i -= 1) {
2246
- const ev = events[i];
2247
- if (ev && ev.event_type === EVENT_TYPE_PROPOSED && typeof ev.ts === "number") {
2248
- watermarkTs = ev.ts;
2249
- break;
2250
- }
2251
- }
2252
- if (!hasHighValueArchiveSignal(events, watermarkTs)) {
2449
+ const sid = resolveHookSessionId(stdinPayload);
2450
+ const watermarkTs = sessionAnchorTs(events, sid);
2451
+ if (!hasHighValueArchiveSignal(events, watermarkTs, sid)) {
2253
2452
  return; // no high-value candidate → stay quiet (D6 value-gate)
2254
2453
  }
2255
2454
  }
@@ -2360,6 +2559,14 @@ module.exports = {
2360
2559
  // for unit testing of the truth table).
2361
2560
  isImportInFlight,
2362
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,
2363
2570
  readCooldownHours,
2364
2571
  readUnderseedThreshold,
2365
2572
  readArchiveEditThreshold,